diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c9776f7..63443f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ All notable changes to AngKorGit are documented here. The format follows ## [Unreleased] +### Added +- Right-click a group header on the start page to **Fetch all** or **Pull all + (fast-forward only)**. A live summary lists moved refs and commit ranges, unchanged + repositories, skipped repositories with reasons, and failures. Pull follows each + checked-out branch's configured upstream and skips detached HEADs, unfinished + operations, diverged history, and local changes that conflict with safe checkout. + Unrelated untracked files and staged or unstaged edits are preserved. Cancel stops the remaining batch, + prevents later ref/worktree updates, and releases the app even if a network read is + stalled; completed updates are kept. Partial fetches have a single outcome in the + summary, including when a remote succeeds without moving refs. Missing upstream + refs are reported separately from unconfigured upstreams. Ref-update errors + after checkout explicitly explain the working-tree/branch mismatch; abandoned run + entries expire on later registry activity. + Group pull uses the same checkout behavior as toolbar Pull. The summary has flat + rows, toned badges, and a single-line counts strip. Per-remote prune settings + override fetch.prune, including an explicit false. Completed checkouts retain their + result when cancelling; settled summary rows ignore late responses and errors. + URL-based upstreams are supported. Fetch cancellation keeps successful remotes and + errors together, with ref summaries calculated outside the write guard; queued + repositories say they never started, and unchanged fetch badges match the counts. + Pull also handles upstreams outside a remote's fetch refspec. Cancellation preserves + completed results if one repository's ref summary becomes unavailable. (#69) + ## [0.23.0] — 2026-10-13 The working-copy release. Folders in the file tree stage and unstage as one, the diff --git a/CLAUDE.md b/CLAUDE.md index 65076423..8828b2d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1039,13 +1039,29 @@ features/ │ │ "Drag a repository here, or move one from its menu."; the │ │ body grows to max-h-96 with groups. data-group-header=, │ │ data-group-section, data-recent-row=, data-group-tile -│ │ are the e2e hooks. HEADER MENU (right-click or "…"): Open all +│ │ are the e2e hooks. HEADER MENU (right-click or "…"): +│ │ Fetch all / Pull all (fast-forward only) / Open all │ │ in tabs / Close its tabs / Move up / Move down / │ │ Edit group… / Ungroup… — repository/groups.ts openRepoGroup (ipc.pathsExist │ │ first, skips missing folders and says so in the toast │ │ description, addRepoTab for every present path, opens the │ │ first only when the current repo is not in the group, then -│ │ ensureRepoRoute), closeRepoGroup, deleteRepoGroup (confirm +│ │ ensureRepoRoute). GroupUpdateDialog + groupUpdate.ts run Fetch all +│ │ and Pull all sequentially with live per-repo results: moved refs, +│ │ commit counts, unchanged repos, skips and failures. The dialog uses +│ │ flat rows, toned badges and one non-wrapping counts line; +│ │ classify() keeps badges and counters consistent. Fetch uses +│ │ RefreshCw and Pull uses ArrowDownToLine, matching the toolbar. +│ │ groupStart(runId) starts the native run; groupUpdate uses that token. +│ │ groupCancel returns completed-result receipts before the frontend +│ │ aborts its wait; groupFinish cleans up. Leaving the page cancels too. +│ │ Cancel retains completed work, stops queued repos and permits no +│ │ ref/worktree writes after acknowledgement. It waits for active local +│ │ writes, not socket I/O; settled rows ignore late responses/errors. +│ │ Pull's checkout behavior matches toolbar Pull exactly. Group pull +│ │ follows the configured upstream and only fast-forwards, never +│ │ merges or rebases. +│ │ closeRepoGroup, deleteRepoGroup (confirm │ │ only when the group has repos; the repos stay in recents), │ │ plural(count, noun) handles -y → -ies. DRAG: rows are │ │ draggable only while groups exist and no search is active; @@ -3559,10 +3575,10 @@ update CLAUDE.md or docs/ — never the code. | Suite | Location | Coverage | | --- | --- | --- | -| Rust integration (92) | `apps/desktop/src-tauri/tests/git_engine.rs` | stage/commit/history, amend, branch/merge(ff+normal+conflict+message), branch-over-tag ref resolution (merge/rebase/history filter), ff-merge preserving uncommitted changes, drag-merge sequence (checkout target → merge source), no-ff merge commit when ff possible, can-fast-forward only when strictly behind, merge message available only during conflicted merge, interactive rebase (reorder/drop + range listing, squash/reword, conflict aborts untouched, invalid-plan rejection), file history lists only touching commits + paginates with skip, conflict resolve, stash (whole tree + selected paths incl. an untracked-only pick, a staged pick that leaves other staged files alone and pops with a dirty index, a staged-new pick removed and restored, git CLI agreeing on stash list/show; stash_files lists tracked + untracked entries with source_oid; stash_restore_files restores chosen files incl. an untracked one and a deletion, keeps the stash, rejects unknown paths), remote-branch checkout (stale local fast-forwarded with an untracked file preserved + upstream set, diverged local kept), tags, cherry-pick (plain keeps the message verbatim; record-origin output byte-equal to `git cherry-pick -x` for plain and trailer-block messages; many: in-order with per-commit origin lines, first conflict stops the run with progress counts), revert, reset (+ unknown-mode error), history pagination with and without filters, history position lookup by full + short hash, history search positions (case-insensitive text, hash prefix, none, blank, author alone, text + author, unknown author), stashes listed in the default walk as single-parent commits with a stash decoration while their index/untracked helper commits stay hidden and a branch-filtered walk omits them, broken-symlink staging (unix), diff hunks + whole-file context, unstage_all/discard_all, discard_staged_file/all (HEAD restored for index + worktree, staged-new file deleted, other staged files untouched), line+hunk ops on files without trailing newline, git-CLI interop, commit signing (SSH sign verified via `git verify-commit`, unsigned without config, amend re-signs, merge commit signed, failure blocks the commit and leaves HEAD/index untouched), PR checkout (fork-style refs/pull fetch creates + updates the local branch and re-runs cleanly, diverged local branch refused with HEAD untouched, same-repo tracking checkout sets the upstream), ref fingerprint (tracks refs/HEAD, ignores plain file edits), commit file lists (per-file counts without hunks), single-file commit diff scoped by pathspec, worktrees (add lists + checks out the branch and reports is_worktree/main_path from inside the linked folder, new branch from a base commit + duplicate refused, branch held elsewhere refused, checkout of a held branch refused with HEAD untouched, dirty remove refused unless forced, prune of a deleted folder, ref fingerprint changes on add, `git worktree list --porcelain` reports the engine-created worktree and `git status` runs clean inside it), discover refuses a missing path instead of walking to the parent repo, push reports up_to_date without contacting the remote when the tip equals the tracking ref while `git push` prints Everything up-to-date, and pushes again after a new commit, push with tags lands a local tag on a bare remote through named refspecs, pull with mode rebase / configured pull.rebase keeps history linear while mode merge creates a merge commit, blame attributes lines to their commits and marks an uncommitted edit, blames at a revision and at `^`, an uncommitted edit inside a committed block keeps the author on both split halves (the G44 crash), untracked/staged-new files and paths missing from a commit get explicit errors, staging and unstaging a later hunk of a multi-hunk file leaves the other hunk alone, remote_add registers a remote that list and fetch then use while a duplicate or blank name is refused, reword of HEAD changes only the message (tree + author lines byte-equal, dirty worktree and staged file untouched), reword of an earlier commit rewrites the commits above it and returns the reworded commit's new oid while a dirty tree is refused, unpushed lists only commits missing from every refs/remotes ref, tree_files lists every path at a commit while commit_files still lists the two changed ones, index_files lists tracked and staged-new paths but no untracked file, file_contents reads a file at a commit and on disk as context lines with status unchanged, errors for a path missing from the commit and flags a NUL-byte file binary, the folder scan finds repos at any depth and repos inside repos while skipping node_modules, hidden folders, an empty `.git` folder and anything past the runaway depth guard, returns the discover-shaped path, a root that is itself a repo plus its nested repo, and an error for a missing folder, stage_files/unstage_files apply many paths in one index write, commit refuses an index that matches HEAD (HEAD and worktree untouched) and an empty first commit while a merge resolved to HEAD's tree still commits with two parents | -| Rust module (78) | `apps/desktop/src-tauri/src/cli.rs` (7), `src/state.rs` (1), `src/editors.rs` (4), `src/fonts.rs` (1), `src/ai_cli.rs` (10), `src/error.rs` (7), `src/core/remote.rs` (18), `src/terminal.rs` (3), `src/core/accounts.rs` (7), `src/core/sign.rs` (7), `src/forge.rs` (7), `src/proc.rs` (1), `src/watcher.rs` (4) | AI-CLI runner: program allowlist (incl. Cursor's `agent` and `cursor-agent` names), stdout capture via fake agent script, {OUTPUT_FILE} substitution, kill-on-timeout, Cursor version-folder keys and the .cmd launcher → newest node.exe/index.js resolution (none for other agents or a missing versions dir) · error mapping: HTTP status extraction from libgit2 messages, 401/402/403 explanations, unmapped codes kept verbatim, io NotFound → not_found while other io errors stay io · forge proxy: api-subdomain host allowlist (dot-anchored), per-provider auth headers, bitbucket email requirement, unknown provider rejected, missing-token vs no-account message · SSH key resolution: `~` expansion, configured key ordered ahead of defaults, dedupe when the configured key IS a default, blank config ignored, generation never targeting an existing key · push refspec shapes (plain/force/tags never forced) · rejection message quotes the server lines after the ref list (sideband lines trimmed, de-duplicated, "remote:" and rule lines dropped, capped at 12) · repo account-binding parse (valid/malformed) · accounts: upsert keeps both same-host accounts + default flags, one default per host, preferred-before-default candidate order, port-loose host match, ssh URLs ignored · signing config: off by default, ssh setup read from git config, empty-string values read as unset, ssh-without-key and x509 are clear errors, openpgp falls back to the committer identity, literal-key detection, ~ expansion · proc: no bare `Command::new` anywhere outside proc.rs (G31) · recents: new paths go on top in order, an existing path moves instead of duplicating, the list caps at RECENT_LIMIT · watcher: metadata filter unchanged for main repos, `worktrees/*/HEAD|gitdir|locked` relevant while `worktrees/*/index` is noise, a linked worktree watches its own gitdir + the shared commondir, main repos need no extra roots | -| Unit (267) | `tests/unit/*.test.ts` | settingsFilter (AND terms, section labels, catalog coverage of every JSX card title), GraphLayout (incl. pagination stability, lane reuse), wordDiff (round-trip), conflict parse/serialize (diff3 labels, CRLF, bare markers, 8+-char content lines, close-without-separator — all lossless), cliAgents (per-agent argv/stdin shape incl. Cursor print mode with the prompt last, ANSI/OSC cleaning, output-file preference, error surfacing), aiProviders (empty/whitespace/missing content rejected for openai-compatible + ollama, HTTP status+body surfaced, real content passes), aiCapabilities (review conventions: absent by default, general-only, general+project order with precedence note, whitespace = absent, clipping), aiTextSegments (token parse: adjacent tokens, unclosed/inner-asterisk/multi-line markers stay literal, ** inside backticks is code), reviewSignature (staged-only, order-insensitive, unstaged-edit + set changes alter it, newline filenames don't collide, hashText determinism), commitStyle (prefix rule matching/tokens/ticket-fallthrough, `$`-sequence literalness, preset instructions, post-generation prefix enforcement), pullRequestUrl (https/scp/ssh remotes, non-standard ports kept, http preserved, ssh port dropped, Bitbucket Server /scm/ shape, .git-behind-slash strip, unknown forge → null), aiModels (per-provider list endpoints/headers incl. Groq-style base URLs, generateContent filtering for Gemini, dedupe/sort, invalid-JSON + HTTP-status errors, cli → empty without a request), forge (parseForgeRemote for all three forges + rejects, provider creation gating, github/gitlab/bitbucket adapters: request URLs, field mapping incl. fork + draft detection, create payloads, error-message surfacing incl. github field-level validation entries without a message, checkoutSpec shapes incl. bitbucket fork → null, pickForgeRemote upstream-first ordering, gitlab self-hosted https→http transport fallback — GET-only (a retried POST could file a duplicate MR), never for gitlab.com or api-level errors, working scheme remembered per host and safe under concurrent fallbacks, reviewer candidates per forge + reviewer ids embedded/requested on create incl. github follow-up failure tolerance, authorAvatar per forge: github commit author, gitlab avatar-by-email URL encoding, bitbucket commit author user), worktree (folder-name slug from repo + branch, sibling-path suggestion incl. Windows separators, parentDirectory), highlight (block-comment continuation: JSDoc carried and closed, line comments and glob strings not treated as open, mid-line close resumes code, xml opener, no-block-comment languages ignore the flag; embedded languages: astro on the xml grammar, svelte/toml mappings, whole-file frontmatter + script/style blocks incl. lang="ts"/"scss" and a single-line script tag, hunk walk from line 1 and mid-file back to the first fence or closer, template-only hunk marks nothing, non-markup → null), commitMessage (split/join round-trip, CRLF, second line without blank line is body, newline never enters the summary), fileFilter (empty/whitespace query passes all, case-insensitive substring, every term required), webUrl (https/scp/ssh:// remotes → repository page, ssh port dropped, http + web port kept, Bitbucket Server browse page, Bitbucket Cloud untouched, trailing slash, local path → null), blameable (hasCommittedHistory true for every tracked change kind, false for untracked and staged-new), imageMime (one media type per engine image extension, null otherwise), renderCap (lines at or under 5,000 chars stay whole, longer ones clip and count the hidden tail, custom cap), fork → upstream pull requests (github head `owner:branch`, gitlab GET target id then POST on the source project with target_project_id, bitbucket source.repository, forgeTargets dedupes same-host remotes and defaultForgeTarget prefers `upstream`), allFiles (tree paths marked with their change, deleted files kept, no duplicate paths, foldersWithChanges lists every ancestor of a changed file and nothing otherwise), fetchRemotes (continues after a failure and reports failures), patchText (headers + hunk headers + prefixed lines, rename old path, multi-file join, hasReviewableText rejects null/binary/image/empty), single-file review + explain prompts (file/where/sibling context lines, sibling cap at 30, section labels, conventions order, patch at the end, 4096-token budget), aiReport (labelled sections, bullet severities, continuation lines, verdict tones, unstructured fallback, bold inside a sentence is not a label), locateDiffLine (exact match prefers additions, deletion/context fallback, whitespace-insensitive whole-line hit, tiny/absent snippets null), commit review + explain prompts (commit line, file list capped at 30, conventions, patch at the end), terminalKeys (Alt+Up/Down map to ESC[1;3A/B on keydown only, every other modifier/key/phase left to xterm), chord (key from event.code so ⌥A stays A, unsupported keys null, id round-trip, exact modifier match, per-platform labels, no-modifier/reserved problems per platform, repoForChord lookup), repoGroups (empty/duplicate names ignoring case and the renamed group, least-used colour with wrap, recents bucketed in order with the rest ungrouped, a deleted group's paths fall back to ungrouped while empty groups stay listed, worktree inherits its main's group unless mapped itself, tabClusters/orderRepoTabs cluster open tabs by group with ungrouped last and leave a group-less strip alone, moveRepoGroup places a group before or after another and ignores self or unknown targets), tabLabels (unique names get no hint, nearest distinguishing parent folder, walks further up when parents collide, case-insensitive names and Windows separators), linePairs + pairHunk (the issue #62 hunk, single replacement always one row, comment block before a rewritten line, dissimilar deletion unpaired, pure runs unchanged, positional fallback past the cap, identical lines anchor, the demo load hunk; lineSimilarity ignores punctuation and repeats and compares symbol-only lines by text), remotes (pickRemote upstream → origin → first, pushSetsUpstream only for no upstream or the same remote) | -| E2E (114) | `tests/e2e/smoke.spec.ts` | folder rows in the working-copy tree stage and unstage everything inside them (one engine call each, chevron still folds, indeterminate in All files), stepping with n / the header chevron / p marks the active change rows and counts "k of n changes" in inline, split and wrapped views, View options stays open across two toggles, Settings filter (autofocus, SSH-only, first catalog hit across sections, locate flash, section-label match, no results, Escape clears then closes, close resets, AI section preset), splash→welcome, the AI chip names Ollama on the demo defaults, reads Set up AI when the CLI provider has no agent, Claude Code once picked, turns ok after Test connection, stale when the provider changes back to Ollama, and survives a reload, Blame is disabled in the file menu and the diff header for the untracked demo file while a tracked file keeps it enabled, the remote row menu's Open in browser opens the demo remote's GitHub page in a popup and the palette lists Open repository in browser, the sidebar and graph branch menus carry a fast-forward entry next to merge that is disabled while the current branch is ahead, settings installs the command line tool, settings lists the demo editors and picking Zed relabels the toolbar button and its menu, the Pull options menu offers merge and rebase, the status bar reports Fetched just now after the auto fetch, retries every remote after a partial auto fetch failure, keeps the timestamp and faint failed remote hint through partial and offline fetches, updates the timestamp on Pull, and explains the disabled Fetch button when no remotes exist, the diff header's Blame button opens file history in the blame pane with the Working copy row selected, per-hunk author buttons and an uncommitted hunk that disappears when a commit row is picked, a Blame at this commit menu, the Diff view toggle brings the diff controls back and Escape returns to the graph, the palette's Blame… picks a file and opens its blame pane, open repo, graph, inspector, palette, search, conflict resolver line picks, single-conflict nav + per-conflict take-all, per-block conflict hand edit, interactive rebase dialog + multi-select squash, cherry-pick dialog with the source-reference checkbox on by default, multi-select cherry-pick listing both commits and confirming, diff auto-jump lands at the first change with no scroll animation (frame-traced scrollTop), long path stays inside the discard confirm dialog, commit action buttons stay inside a narrow working copy panel (the row wraps), sidebar branch names align with and without the HEAD tick (measured left offsets), hovering a working-copy file reveals its full path, opening a diff folds the sidebar away and the toggle brings back the graph, avatars stay visible after a diff open/close round-trip (stubbed Gravatar), diff text selection survives the right-click copy menu and Esc closes only the menu, sidebar lists the demo pull requests and the create dialog opens, reconnecting an account opens the prefilled token form with focus in the token field, a file history row's Open commit button closes the panel and selects that commit in the graph, the author box finds commits with the graph lanes intact and combines with the text search, commit search finds matches in the full graph (n of m pill, Enter/Previous step, query survives selecting another commit, Escape clears), a search result that lands after a click keeps the clicked commit selected while clicking a match moves the pill to 2 of n and Enter steps to 3, searching a commit hash jumps to it in the full graph, a short hash prefix jumps too while an unknown hex word says No matches, a missing hash keeps the graph with No matches, mod+f focuses the commit search, sidebar lists the demo worktrees (incl. a missing-folder row) and the new-worktree dialog re-suggests the folder as the branch name is typed, the inner line of a JSDoc block in the demo CommitGraph.tsx diff renders as one hljs-comment span (guards the DiffViewer → prepareCommentStates wiring, which the unit tests cannot see), collapse-all folds every section and branch folder and a reopened section comes back with its folders closed, the inspector keeps its width (±1px) when a diff folds the sidebar away and both widths return on Escape (measured via data-panel-id after dragging the sidebar handle), the commit box renders the summary larger and bolder than the description with Enter/Backspace moving between them and commit disabled on an empty summary, dragging the commit box's top edge grows the description and double-click resets it, the Changes header's fold button collapses that list's folders (nested file hidden) and flips to expand-all, and no fold button exists in flat mode, the top graph row shows a whole `main` chip with no HEAD chip and no clipped chip text plus a 7-char hash copy button, the graph display menu hides the hash column and brings it back, the sidebar accordion pins collapsed headers at the bottom under an open Branches section and moves the Tags header up when Tags opens, the welcome page flags the demo's missing folder and opens a repository via ↓ + ⏎ from the autofocused search, the conflict resolver renders positive integer line numbers in A, B and Result with each starting at 1, the checked-out branch chip is opaque while another local branch's chip stays translucent, double-clicking the separated `origin/main` chip opens the reset prompt naming the 2 commits that would be lost and Cancel dismisses it (the demo repo puts origin/main 2 commits behind main so the split is real), the diff header's History button opens that same file's history panel and closes the diff, a working-copy row's "Stash this file…" opens the dialog naming that file and the toolbar pop button restores the latest demo stash, shift-click selects three working-copy rows and the bulk menu offers Stage 3 / Stash 3 files, the working-copy filter hides non-matching rows with "1 of 2" counts and the clear button restores them, the commit file filter narrows five demo files to one and Escape resets it, selecting the demo stash shows the stash hint and the hover restore button on a file toasts the restore, right-clicking a plain commit's file offers File history / Show in Finder / Copy path and no stash Apply entry, staged rows offer discard from the row, the menu and the header trash icon, the sidebar is visible again after a reload that happened with a diff open, the demo stash renders as a graph row with an Archive node and a dashed chip whose menu pops it, ↓ then → opens the second commit's first file with the file list focused, ↓/↑ walk files, twelve → presses stay on the same file and one ← returns to the graph on the same commit, the Graph display menu's "Lane color band" removes and restores the tail rects, the inspector stops at its minimum width when dragged and folds/returns for file history, dragging the sidebar shut and back open in one gesture shows its content again, conflict picks land in file order with a mixed-state side checkbox, the resolver picks with A/B/↑/↓ and ⌘⏎ opens the next conflicted file, leaving with picks asks first while Escape closes a clean resolver, right-clicking the main tip row offers Push main ↑2 while an inner commit's menu has no push, hovering the feature/diff-viewer chip stacks both its refs in a panel whose first chip sits on the row chip (≤2px) with the folded `release/0.4` under it, and right-clicking that one opens its Checkout menu (no reset entry for a plain local), the top row shows `main` as its visible chip although `hotfix/lane-colors` comes first in the refs and only `main` carries the check inside the stacked panel, right-clicking the separated `origin/main` chip offers "Reset main to this…" and opens the same reset dialog, clicking a working-copy file then ↓/↓/↑ moves the diff through ipc.ts → palette-seed.sql → Architecture.md and back — the Remotes header's Add remote button opens the add dialog with the button disabled until name and URL are filled, right-clicking the terminal shows Copy (disabled without a selection)/Paste/Select all/Clear terminal, Settings → Git → Clone destination remembers a chosen folder and the clone dialog's destination starts there, a four-row drag selection in the palette-seed diff stays a Range and still copies all four lines via ⌘C after its rows scroll out of the virtualizer in either direction, and reads the same text once scrolled back, the pushed demo commit opens the editor and Save asks "Rewrite a pushed commit?" (Cancel keeps the draft) while the top commit opens the inline editor on double-click (Esc cancels, Save rewrites the heading, body and graph row), the GitHub account form keeps the classic token link and adds the fine-grained page inline while the Token label still focuses the input, the Fonts card's Interface picker lists proportional fonts first and Code lists monospace first, picking Helvetica Neue/Fira Code changes --font-sans/--font-mono and the terminal follows Code until Menlo + 16 px is picked for it, all of it survives a reload and Reset fonts clears it, sidebar section headers and bodies carry no fill and no top border while each header shows a filled gold icon tile and a filled count badge, the commit Files header's A token narrows five files to one with All pressed off and back, the All files view lists Roadmap.md at the commit with 5 changed, keeps a change-free folder collapsed until clicked, opens the unchanged file with an unchanged badge and no @@ header while Folder tree hides it again, the working copy's All files view shows README.md beside the stage/unstage checkboxes of the changed files and opens it without a Stage file button, dragging a selection below the diff grows it past six lines from the start row and ⌘C copies the same text before and after scrolling away, the diff header's AI actions menu reviews the open file (busy strip → demo response under the header, Copy, fold to one line and back, full-view dialog, button disabled meanwhile, closing and reopening the diff dismisses the result) then explains it and Dismiss removes the panel, the inspector's Review with AI reviews the selected commit (Stop reviewing while busy, AI review card → demo response, Dismiss), file history's diff pane shows no change arrows on a commit that did not touch palette-seed.sql and, on the Working copy row, `n` jumps the scroller to the one change, `p` from the top wraps to it and the header's Next change button lands on the same offset, a second repo opened from the palette becomes a tab that ⌘1 / ⌘⇧] switch between (wrapping), the tab menu's Keyboard shortcut… capture box flags a bare `t` as no_modifier and ⌘K as reserved without opening the palette, saving ⌃⇧T shows the chip on the tab and the chord switches to it, and Settings → Shortcuts lists and removes it, Force push from the Push menu opens a "Force push?" confirm naming origin/main that Cancel dismisses without pushing, and repositories can be grouped on the welcome page: Add to group → New group… creates Frontend with a picked colour (Create disabled on an empty name, a duplicate name shows the taken hint), dragging a row onto the header moves it (counts 2 and 2), Enter in the name field creates Backend, the header menu's Move up puts Backend first (Move down disabled at the bottom) and Move down restores it, the header button collapses and re-expands it, searching flattens the list to the two matching rows, Open all in tabs opens both Frontend repos as tabs whose titles end in · Frontend, the tab menu's Move to group re-homes one to Backend and the strip splits into two clusters with chips, clicking the Backend chip hides its tab and shows Backend1, collapsing Frontend keeps the active tab visible, ⌘2 switches to the hidden temple-ui and reveals it, both chips expand again, the Backend chip's menu counts 1 under Close its tabs and Collapse other groups folds Frontend to Frontend1 while ⌘1 still reaches its hidden tab, Expand all groups brings both back, dragging the Backend chip onto the first cluster's left edge puts Backend first so ⌘1 reaches temple-ui and dragging it back restores Frontend, narrowing the window to 380px shows the overflow list whose temple-ui entry switches tabs and widening hides it again, no separators or overflow button exist with two tabs, the switcher lists repos under data-switcher-group labels, the palette's Close all in Backend closes its one open tab while Open all in Backend says 2 repositories, and Ungroup… confirms and leaves the repos in the list, the long-line diff scrollbar stays at the viewport bottom, pans both split panes together, accepts arrow keys and Shift+wheel, and disappears when wrapping is enabled, the code diff opened from a selected commit also scrolls horizontally in inline and split views, and a trackpad pan of one wheel event per frame moves the long-line diff by the whole gesture with no backward step, dragging the drawn thumb moves the layers by exactly the engine's scrollLeft and a click on the track pages back to 0, and a text selection dragged past the pane's right edge pans the long lines, grows the selection and stops panning when the pointer comes back, scanning ~/projects from the welcome page lists four repos with two marked In recents and disabled, unticking one adds the other to the recents list, a seeded tab whose folder is missing closes with a toast while two same-named angkorgit tabs show forks / projects hints and a unique one shows none, ⌘P on the welcome page filters four recents to temple-ui and Enter opens it as the only tab, ⌘P marks it Current and opens angkorgit as a second tab, a third ⌘P marks temple-ui Open tab and switches back without a new tab, ⌘K still opens the command palette, and ⌘T opens a picked folder as a third tab, the all files layout stacks the five demo commit files with GraphRow.tsx scrolled to the top and "2 of 5", clicking Architecture.md in the inspector scrolls it to the top ("4 of 5"), side by side applies inside the cards, a user wheel at the top moves the inspector highlight to CommitGraph.tsx, one file and then all files collapse and expand, Show one file returns to DiffPanel and the choice survives a reload, clicking a file in the inspector still jumps to it after scrolling reached it once and ← inside the page returns to the graph, a stash file never offers the all files layout even with 'all' remembered while a plain commit still gets it, a click into a file diff followed by ← closes it and returns to the graph, ⌘⏎ with nothing staged toasts "Stage some changes first" and commits nothing, and a file filter that hides every staged file leaves Commit enabled with the full count — all on demo mode | +| Rust integration (109) | `apps/desktop/src-tauri/tests/git_engine.rs` | stage/commit/history, amend, branch/merge(ff+normal+conflict+message), branch-over-tag ref resolution (merge/rebase/history filter), ff-merge preserving uncommitted changes, drag-merge sequence (checkout target → merge source), no-ff merge commit when ff possible, can-fast-forward only when strictly behind, merge message available only during conflicted merge, interactive rebase (reorder/drop + range listing, squash/reword, conflict aborts untouched, invalid-plan rejection), file history lists only touching commits + paginates with skip, conflict resolve, stash (whole tree + selected paths incl. an untracked-only pick, a staged pick that leaves other staged files alone and pops with a dirty index, a staged-new pick removed and restored, git CLI agreeing on stash list/show; stash_files lists tracked + untracked entries with source_oid; stash_restore_files restores chosen files incl. an untracked one and a deletion, keeps the stash, rejects unknown paths), remote-branch checkout (stale local fast-forwarded with an untracked file preserved + upstream set, diverged local kept), tags, cherry-pick (plain keeps the message verbatim; record-origin output byte-equal to `git cherry-pick -x` for plain and trailer-block messages; many: in-order with per-commit origin lines, first conflict stops the run with progress counts), revert, reset (+ unknown-mode error), history pagination with and without filters, history position lookup by full + short hash, history search positions (case-insensitive text, hash prefix, none, blank, author alone, text + author, unknown author), stashes listed in the default walk as single-parent commits with a stash decoration while their index/untracked helper commits stay hidden and a branch-filtered walk omits them, broken-symlink staging (unix), diff hunks + whole-file context, unstage_all/discard_all, discard_staged_file/all (HEAD restored for index + worktree, staged-new file deleted, other staged files untouched), line+hunk ops on files without trailing newline, git-CLI interop, commit signing (SSH sign verified via `git verify-commit`, unsigned without config, amend re-signs, merge commit signed, failure blocks the commit and leaves HEAD/index untouched), PR checkout (fork-style refs/pull fetch creates + updates the local branch and re-runs cleanly, diverged local branch refused with HEAD untouched, same-repo tracking checkout sets the upstream), ref fingerprint (tracks refs/HEAD, ignores plain file edits), commit file lists (per-file counts without hunks), single-file commit diff scoped by pathspec, worktrees (add lists + checks out the branch and reports is_worktree/main_path from inside the linked folder, new branch from a base commit + duplicate refused, branch held elsewhere refused, checkout of a held branch refused with HEAD untouched, dirty remove refused unless forced, prune of a deleted folder, ref fingerprint changes on add, `git worktree list --porcelain` reports the engine-created worktree and `git status` runs clean inside it), discover refuses a missing path instead of walking to the parent repo, push reports up_to_date without contacting the remote when the tip equals the tracking ref while `git push` prints Everything up-to-date, and pushes again after a new commit, push with tags lands a local tag on a bare remote through named refspecs, pull with mode rebase / configured pull.rebase keeps history linear while mode merge creates a merge commit, blame attributes lines to their commits and marks an uncommitted edit, blames at a revision and at `^`, an uncommitted edit inside a committed block keeps the author on both split halves (the G44 crash), untracked/staged-new files and paths missing from a commit get explicit errors, staging and unstaging a later hunk of a multi-hunk file leaves the other hunk alone, remote_add registers a remote that list and fetch then use while a duplicate or blank name is refused, reword of HEAD changes only the message (tree + author lines byte-equal, dirty worktree and staged file untouched), reword of an earlier commit rewrites the commits above it and returns the reworded commit's new oid while a dirty tree is refused, unpushed lists only commits missing from every refs/remotes ref, tree_files lists every path at a commit while commit_files still lists the two changed ones, index_files lists tracked and staged-new paths but no untracked file, file_contents reads a file at a commit and on disk as context lines with status unchanged, errors for a path missing from the commit and flags a NUL-byte file binary, the folder scan finds repos at any depth and repos inside repos while skipping node_modules, hidden folders, an empty `.git` folder and anything past the runaway depth guard, returns the discover-shaped path, a root that is itself a repo plus its nested repo, and an error for a missing folder, stage_files/unstage_files apply many paths in one index write, commit refuses an index that matches HEAD (HEAD and worktree untouched) and an empty first commit while a merge resolved to HEAD's tree still commits with two parents | +| Rust module (79) | `apps/desktop/src-tauri/src/cli.rs` (7), `src/state.rs` (1), `src/editors.rs` (4), `src/fonts.rs` (1), `src/ai_cli.rs` (10), `src/error.rs` (7), `src/core/remote.rs` (18), `src/terminal.rs` (3), `src/core/accounts.rs` (7), `src/core/sign.rs` (7), `src/forge.rs` (7), `src/proc.rs` (1), `src/watcher.rs` (4) | AI-CLI runner: program allowlist (incl. Cursor's `agent` and `cursor-agent` names), stdout capture via fake agent script, {OUTPUT_FILE} substitution, kill-on-timeout, Cursor version-folder keys and the .cmd launcher → newest node.exe/index.js resolution (none for other agents or a missing versions dir) · error mapping: HTTP status extraction from libgit2 messages, 401/402/403 explanations, unmapped codes kept verbatim, io NotFound → not_found while other io errors stay io · forge proxy: api-subdomain host allowlist (dot-anchored), per-provider auth headers, bitbucket email requirement, unknown provider rejected, missing-token vs no-account message · SSH key resolution: `~` expansion, configured key ordered ahead of defaults, dedupe when the configured key IS a default, blank config ignored, generation never targeting an existing key · push refspec shapes (plain/force/tags never forced) · rejection message quotes the server lines after the ref list (sideband lines trimmed, de-duplicated, "remote:" and rule lines dropped, capped at 12) · repo account-binding parse (valid/malformed) · accounts: upsert keeps both same-host accounts + default flags, one default per host, preferred-before-default candidate order, port-loose host match, ssh URLs ignored · signing config: off by default, ssh setup read from git config, empty-string values read as unset, ssh-without-key and x509 are clear errors, openpgp falls back to the committer identity, literal-key detection, ~ expansion · proc: no bare `Command::new` anywhere outside proc.rs (G31) · recents: new paths go on top in order, an existing path moves instead of duplicating, the list caps at RECENT_LIMIT · watcher: metadata filter unchanged for main repos, `worktrees/*/HEAD|gitdir|locked` relevant while `worktrees/*/index` is noise, a linked worktree watches its own gitdir + the shared commondir, main repos need no extra roots | +| Unit (274) | `tests/unit/*.test.ts` | settingsFilter (AND terms, section labels, catalog coverage of every JSX card title), GraphLayout (incl. pagination stability, lane reuse), wordDiff (round-trip), conflict parse/serialize (diff3 labels, CRLF, bare markers, 8+-char content lines, close-without-separator — all lossless), cliAgents (per-agent argv/stdin shape incl. Cursor print mode with the prompt last, ANSI/OSC cleaning, output-file preference, error surfacing), aiProviders (empty/whitespace/missing content rejected for openai-compatible + ollama, HTTP status+body surfaced, real content passes), aiCapabilities (review conventions: absent by default, general-only, general+project order with precedence note, whitespace = absent, clipping), aiTextSegments (token parse: adjacent tokens, unclosed/inner-asterisk/multi-line markers stay literal, ** inside backticks is code), reviewSignature (staged-only, order-insensitive, unstaged-edit + set changes alter it, newline filenames don't collide, hashText determinism), commitStyle (prefix rule matching/tokens/ticket-fallthrough, `$`-sequence literalness, preset instructions, post-generation prefix enforcement), pullRequestUrl (https/scp/ssh remotes, non-standard ports kept, http preserved, ssh port dropped, Bitbucket Server /scm/ shape, .git-behind-slash strip, unknown forge → null), aiModels (per-provider list endpoints/headers incl. Groq-style base URLs, generateContent filtering for Gemini, dedupe/sort, invalid-JSON + HTTP-status errors, cli → empty without a request), forge (parseForgeRemote for all three forges + rejects, provider creation gating, github/gitlab/bitbucket adapters: request URLs, field mapping incl. fork + draft detection, create payloads, error-message surfacing incl. github field-level validation entries without a message, checkoutSpec shapes incl. bitbucket fork → null, pickForgeRemote upstream-first ordering, gitlab self-hosted https→http transport fallback — GET-only (a retried POST could file a duplicate MR), never for gitlab.com or api-level errors, working scheme remembered per host and safe under concurrent fallbacks, reviewer candidates per forge + reviewer ids embedded/requested on create incl. github follow-up failure tolerance, authorAvatar per forge: github commit author, gitlab avatar-by-email URL encoding, bitbucket commit author user), worktree (folder-name slug from repo + branch, sibling-path suggestion incl. Windows separators, parentDirectory), highlight (block-comment continuation: JSDoc carried and closed, line comments and glob strings not treated as open, mid-line close resumes code, xml opener, no-block-comment languages ignore the flag; embedded languages: astro on the xml grammar, svelte/toml mappings, whole-file frontmatter + script/style blocks incl. lang="ts"/"scss" and a single-line script tag, hunk walk from line 1 and mid-file back to the first fence or closer, template-only hunk marks nothing, non-markup → null), commitMessage (split/join round-trip, CRLF, second line without blank line is body, newline never enters the summary), fileFilter (empty/whitespace query passes all, case-insensitive substring, every term required), webUrl (https/scp/ssh:// remotes → repository page, ssh port dropped, http + web port kept, Bitbucket Server browse page, Bitbucket Cloud untouched, trailing slash, local path → null), blameable (hasCommittedHistory true for every tracked change kind, false for untracked and staged-new), imageMime (one media type per engine image extension, null otherwise), renderCap (lines at or under 5,000 chars stay whole, longer ones clip and count the hidden tail, custom cap), fork → upstream pull requests (github head `owner:branch`, gitlab GET target id then POST on the source project with target_project_id, bitbucket source.repository, forgeTargets dedupes same-host remotes and defaultForgeTarget prefers `upstream`), allFiles (tree paths marked with their change, deleted files kept, no duplicate paths, foldersWithChanges lists every ancestor of a changed file and nothing otherwise), fetchRemotes (continues after a failure and reports failures), patchText (headers + hunk headers + prefixed lines, rename old path, multi-file join, hasReviewableText rejects null/binary/image/empty), single-file review + explain prompts (file/where/sibling context lines, sibling cap at 30, section labels, conventions order, patch at the end, 4096-token budget), aiReport (labelled sections, bullet severities, continuation lines, verdict tones, unstructured fallback, bold inside a sentence is not a label), locateDiffLine (exact match prefers additions, deletion/context fallback, whitespace-insensitive whole-line hit, tiny/absent snippets null), commit review + explain prompts (commit line, file list capped at 30, conventions, patch at the end), terminalKeys (Alt+Up/Down map to ESC[1;3A/B on keydown only, every other modifier/key/phase left to xterm), chord (key from event.code so ⌥A stays A, unsupported keys null, id round-trip, exact modifier match, per-platform labels, no-modifier/reserved problems per platform, repoForChord lookup), repoGroups (empty/duplicate names ignoring case and the renamed group, least-used colour with wrap, recents bucketed in order with the rest ungrouped, a deleted group's paths fall back to ungrouped while empty groups stay listed, worktree inherits its main's group unless mapped itself, tabClusters/orderRepoTabs cluster open tabs by group with ungrouped last and leave a group-less strip alone, moveRepoGroup places a group before or after another and ignores self or unknown targets), tabLabels (unique names get no hint, nearest distinguishing parent folder, walks further up when parents collide, case-insensitive names and Windows separators), linePairs + pairHunk (the issue #62 hunk, single replacement always one row, comment block before a rewritten line, dissimilar deletion unpaired, pure runs unchanged, positional fallback past the cap, identical lines anchor, the demo load hunk; lineSimilarity ignores punctuation and repeats and compares symbol-only lines by text), remotes (pickRemote upstream → origin → first, pushSetsUpstream only for no upstream or the same remote) | +| E2E (117) | `tests/e2e/smoke.spec.ts` | folder rows in the working-copy tree stage and unstage everything inside them (one engine call each, chevron still folds, indeterminate in All files), stepping with n / the header chevron / p marks the active change rows and counts "k of n changes" in inline, split and wrapped views, View options stays open across two toggles, Settings filter (autofocus, SSH-only, first catalog hit across sections, locate flash, section-label match, no results, Escape clears then closes, close resets, AI section preset), splash→welcome, the AI chip names Ollama on the demo defaults, reads Set up AI when the CLI provider has no agent, Claude Code once picked, turns ok after Test connection, stale when the provider changes back to Ollama, and survives a reload, Blame is disabled in the file menu and the diff header for the untracked demo file while a tracked file keeps it enabled, the remote row menu's Open in browser opens the demo remote's GitHub page in a popup and the palette lists Open repository in browser, the sidebar and graph branch menus carry a fast-forward entry next to merge that is disabled while the current branch is ahead, settings installs the command line tool, settings lists the demo editors and picking Zed relabels the toolbar button and its menu, the Pull options menu offers merge and rebase, the status bar reports Fetched just now after the auto fetch, retries every remote after a partial auto fetch failure, keeps the timestamp and faint failed remote hint through partial and offline fetches, updates the timestamp on Pull, and explains the disabled Fetch button when no remotes exist, the diff header's Blame button opens file history in the blame pane with the Working copy row selected, per-hunk author buttons and an uncommitted hunk that disappears when a commit row is picked, a Blame at this commit menu, the Diff view toggle brings the diff controls back and Escape returns to the graph, the palette's Blame… picks a file and opens its blame pane, open repo, graph, inspector, palette, search, conflict resolver line picks, single-conflict nav + per-conflict take-all, per-block conflict hand edit, interactive rebase dialog + multi-select squash, cherry-pick dialog with the source-reference checkbox on by default, multi-select cherry-pick listing both commits and confirming, diff auto-jump lands at the first change with no scroll animation (frame-traced scrollTop), long path stays inside the discard confirm dialog, commit action buttons stay inside a narrow working copy panel (the row wraps), sidebar branch names align with and without the HEAD tick (measured left offsets), hovering a working-copy file reveals its full path, opening a diff folds the sidebar away and the toggle brings back the graph, avatars stay visible after a diff open/close round-trip (stubbed Gravatar), diff text selection survives the right-click copy menu and Esc closes only the menu, sidebar lists the demo pull requests and the create dialog opens, reconnecting an account opens the prefilled token form with focus in the token field, a file history row's Open commit button closes the panel and selects that commit in the graph, the author box finds commits with the graph lanes intact and combines with the text search, commit search finds matches in the full graph (n of m pill, Enter/Previous step, query survives selecting another commit, Escape clears), a search result that lands after a click keeps the clicked commit selected while clicking a match moves the pill to 2 of n and Enter steps to 3, searching a commit hash jumps to it in the full graph, a short hash prefix jumps too while an unknown hex word says No matches, a missing hash keeps the graph with No matches, mod+f focuses the commit search, sidebar lists the demo worktrees (incl. a missing-folder row) and the new-worktree dialog re-suggests the folder as the branch name is typed, the inner line of a JSDoc block in the demo CommitGraph.tsx diff renders as one hljs-comment span (guards the DiffViewer → prepareCommentStates wiring, which the unit tests cannot see), collapse-all folds every section and branch folder and a reopened section comes back with its folders closed, the inspector keeps its width (±1px) when a diff folds the sidebar away and both widths return on Escape (measured via data-panel-id after dragging the sidebar handle), the commit box renders the summary larger and bolder than the description with Enter/Backspace moving between them and commit disabled on an empty summary, dragging the commit box's top edge grows the description and double-click resets it, the Changes header's fold button collapses that list's folders (nested file hidden) and flips to expand-all, and no fold button exists in flat mode, the top graph row shows a whole `main` chip with no HEAD chip and no clipped chip text plus a 7-char hash copy button, the graph display menu hides the hash column and brings it back, the sidebar accordion pins collapsed headers at the bottom under an open Branches section and moves the Tags header up when Tags opens, the welcome page flags the demo's missing folder and opens a repository via ↓ + ⏎ from the autofocused search, the conflict resolver renders positive integer line numbers in A, B and Result with each starting at 1, the checked-out branch chip is opaque while another local branch's chip stays translucent, double-clicking the separated `origin/main` chip opens the reset prompt naming the 2 commits that would be lost and Cancel dismisses it (the demo repo puts origin/main 2 commits behind main so the split is real), the diff header's History button opens that same file's history panel and closes the diff, a working-copy row's "Stash this file…" opens the dialog naming that file and the toolbar pop button restores the latest demo stash, shift-click selects three working-copy rows and the bulk menu offers Stage 3 / Stash 3 files, the working-copy filter hides non-matching rows with "1 of 2" counts and the clear button restores them, the commit file filter narrows five demo files to one and Escape resets it, selecting the demo stash shows the stash hint and the hover restore button on a file toasts the restore, right-clicking a plain commit's file offers File history / Show in Finder / Copy path and no stash Apply entry, staged rows offer discard from the row, the menu and the header trash icon, the sidebar is visible again after a reload that happened with a diff open, the demo stash renders as a graph row with an Archive node and a dashed chip whose menu pops it, ↓ then → opens the second commit's first file with the file list focused, ↓/↑ walk files, twelve → presses stay on the same file and one ← returns to the graph on the same commit, the Graph display menu's "Lane color band" removes and restores the tail rects, the inspector stops at its minimum width when dragged and folds/returns for file history, dragging the sidebar shut and back open in one gesture shows its content again, conflict picks land in file order with a mixed-state side checkbox, the resolver picks with A/B/↑/↓ and ⌘⏎ opens the next conflicted file, leaving with picks asks first while Escape closes a clean resolver, right-clicking the main tip row offers Push main ↑2 while an inner commit's menu has no push, hovering the feature/diff-viewer chip stacks both its refs in a panel whose first chip sits on the row chip (≤2px) with the folded `release/0.4` under it, and right-clicking that one opens its Checkout menu (no reset entry for a plain local), the top row shows `main` as its visible chip although `hotfix/lane-colors` comes first in the refs and only `main` carries the check inside the stacked panel, right-clicking the separated `origin/main` chip offers "Reset main to this…" and opens the same reset dialog, clicking a working-copy file then ↓/↓/↑ moves the diff through ipc.ts → palette-seed.sql → Architecture.md and back — the Remotes header's Add remote button opens the add dialog with the button disabled until name and URL are filled, right-clicking the terminal shows Copy (disabled without a selection)/Paste/Select all/Clear terminal, Settings → Git → Clone destination remembers a chosen folder and the clone dialog's destination starts there, a four-row drag selection in the palette-seed diff stays a Range and still copies all four lines via ⌘C after its rows scroll out of the virtualizer in either direction, and reads the same text once scrolled back, the pushed demo commit opens the editor and Save asks "Rewrite a pushed commit?" (Cancel keeps the draft) while the top commit opens the inline editor on double-click (Esc cancels, Save rewrites the heading, body and graph row), the GitHub account form keeps the classic token link and adds the fine-grained page inline while the Token label still focuses the input, the Fonts card's Interface picker lists proportional fonts first and Code lists monospace first, picking Helvetica Neue/Fira Code changes --font-sans/--font-mono and the terminal follows Code until Menlo + 16 px is picked for it, all of it survives a reload and Reset fonts clears it, sidebar section headers and bodies carry no fill and no top border while each header shows a filled gold icon tile and a filled count badge, the commit Files header's A token narrows five files to one with All pressed off and back, the All files view lists Roadmap.md at the commit with 5 changed, keeps a change-free folder collapsed until clicked, opens the unchanged file with an unchanged badge and no @@ header while Folder tree hides it again, the working copy's All files view shows README.md beside the stage/unstage checkboxes of the changed files and opens it without a Stage file button, dragging a selection below the diff grows it past six lines from the start row and ⌘C copies the same text before and after scrolling away, the diff header's AI actions menu reviews the open file (busy strip → demo response under the header, Copy, fold to one line and back, full-view dialog, button disabled meanwhile, closing and reopening the diff dismisses the result) then explains it and Dismiss removes the panel, the inspector's Review with AI reviews the selected commit (Stop reviewing while busy, AI review card → demo response, Dismiss), file history's diff pane shows no change arrows on a commit that did not touch palette-seed.sql and, on the Working copy row, `n` jumps the scroller to the one change, `p` from the top wraps to it and the header's Next change button lands on the same offset, a second repo opened from the palette becomes a tab that ⌘1 / ⌘⇧] switch between (wrapping), the tab menu's Keyboard shortcut… capture box flags a bare `t` as no_modifier and ⌘K as reserved without opening the palette, saving ⌃⇧T shows the chip on the tab and the chord switches to it, and Settings → Shortcuts lists and removes it, Force push from the Push menu opens a "Force push?" confirm naming origin/main that Cancel dismisses without pushing, and repositories can be grouped on the welcome page: Add to group → New group… creates Frontend with a picked colour (Create disabled on an empty name, a duplicate name shows the taken hint), dragging a row onto the header moves it (counts 2 and 2), Enter in the name field creates Backend, the header menu's Move up puts Backend first (Move down disabled at the bottom) and Move down restores it, the header button collapses and re-expands it, searching flattens the list to the two matching rows, Open all in tabs opens both Frontend repos as tabs whose titles end in · Frontend, the tab menu's Move to group re-homes one to Backend and the strip splits into two clusters with chips, clicking the Backend chip hides its tab and shows Backend1, collapsing Frontend keeps the active tab visible, ⌘2 switches to the hidden temple-ui and reveals it, both chips expand again, the Backend chip's menu counts 1 under Close its tabs and Collapse other groups folds Frontend to Frontend1 while ⌘1 still reaches its hidden tab, Expand all groups brings both back, dragging the Backend chip onto the first cluster's left edge puts Backend first so ⌘1 reaches temple-ui and dragging it back restores Frontend, narrowing the window to 380px shows the overflow list whose temple-ui entry switches tabs and widening hides it again, no separators or overflow button exist with two tabs, the switcher lists repos under data-switcher-group labels, the palette's Close all in Backend closes its one open tab while Open all in Backend says 2 repositories, and Ungroup… confirms and leaves the repos in the list, the long-line diff scrollbar stays at the viewport bottom, pans both split panes together, accepts arrow keys and Shift+wheel, and disappears when wrapping is enabled, the code diff opened from a selected commit also scrolls horizontally in inline and split views, and a trackpad pan of one wheel event per frame moves the long-line diff by the whole gesture with no backward step, dragging the drawn thumb moves the layers by exactly the engine's scrollLeft and a click on the track pages back to 0, and a text selection dragged past the pane's right edge pans the long lines, grows the selection and stops panning when the pointer comes back, scanning ~/projects from the welcome page lists four repos with two marked In recents and disabled, unticking one adds the other to the recents list, a seeded tab whose folder is missing closes with a toast while two same-named angkorgit tabs show forks / projects hints and a unique one shows none, ⌘P on the welcome page filters four recents to temple-ui and Enter opens it as the only tab, ⌘P marks it Current and opens angkorgit as a second tab, a third ⌘P marks temple-ui Open tab and switches back without a new tab, ⌘K still opens the command palette, and ⌘T opens a picked folder as a third tab, the all files layout stacks the five demo commit files with GraphRow.tsx scrolled to the top and "2 of 5", clicking Architecture.md in the inspector scrolls it to the top ("4 of 5"), side by side applies inside the cards, a user wheel at the top moves the inspector highlight to CommitGraph.tsx, one file and then all files collapse and expand, Show one file returns to DiffPanel and the choice survives a reload, clicking a file in the inspector still jumps to it after scrolling reached it once and ← inside the page returns to the graph, a stash file never offers the all files layout even with 'all' remembered while a plain commit still gets it, a click into a file diff followed by ← closes it and returns to the graph, ⌘⏎ with nothing staged toasts "Stage some changes first" and commits nothing, and a file filter that hides every staged file leaves Commit enabled with the full count — all on demo mode | ## 9.5 Open-source & community files diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 47531655..95cda794 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -497,6 +497,31 @@ pub async fn remote_fetch( blocking(move || remote::fetch(&path, &remote, tags, prune)).await } +#[tauri::command] +pub async fn repository_group_update( + path: String, + pull: bool, + runId: String, +) -> AppResult { + let control = crate::core::group_update::control(&runId)?; + blocking(move || remote::group_update_controlled(&path, pull, &control)).await +} + +#[tauri::command] +pub fn repository_group_start(runId: String) -> AppResult<()> { + crate::core::group_update::start(&runId) +} + +#[tauri::command] +pub async fn repository_group_cancel(runId: String) -> AppResult> { + blocking(move || crate::core::group_update::cancel(&runId)).await +} + +#[tauri::command] +pub async fn repository_group_finish(runId: String) -> AppResult<()> { + blocking(move || crate::core::group_update::finish(&runId)).await +} + #[tauri::command] pub async fn remote_pull( path: String, diff --git a/apps/desktop/src-tauri/src/core/group_update.rs b/apps/desktop/src-tauri/src/core/group_update.rs new file mode 100644 index 00000000..421afa46 --- /dev/null +++ b/apps/desktop/src-tauri/src/core/group_update.rs @@ -0,0 +1,287 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; +use std::time::{Duration, Instant}; + +use super::types::{GroupUpdateReceipt, GroupUpdateResult}; +use crate::error::{AppError, AppResult}; + +#[derive(Default)] +pub struct GroupUpdateControl { + cancelled: Mutex, + completed: Mutex>, +} + +#[derive(Clone)] +enum Completed { + Result(GroupUpdateResult), + Fetch(FetchProgress), +} + +#[derive(Clone)] +pub(super) struct FetchProgress { + pub before: Arc>, + pub succeeded: usize, + pub total: usize, + pub failures: Vec, +} + +impl GroupUpdateControl { + pub fn cancel(&self) -> AppResult<()> { + *self + .cancelled + .lock() + .map_err(|_| AppError::other("Group update lock poisoned"))? = true; + Ok(()) + } + + pub fn cancel_results(&self) -> AppResult> { + self.cancel()?; + let completed = self + .completed + .lock() + .map_err(|_| AppError::other("Group results lock poisoned"))? + .clone(); + Ok(completed + .into_iter() + .map(|(path, completed)| { + let result = match completed { + Completed::Result(result) => result, + Completed::Fetch(progress) => { + match super::repo::open(&path).and_then(|repo| { + super::remote::fetch_progress_result(&repo, &progress, true) + }) { + Ok(result) => result, + Err(error) => { + let mut result = super::remote::fetch_progress_summary( + &progress, + true, + Vec::new(), + ); + if progress.succeeded > 0 { + result.status = "partial".into(); + } + result + .message + .push_str(&format!(". Could not inspect moved refs: {error}")); + result + } + } + } + }; + GroupUpdateReceipt { path, result } + }) + .collect()) + } + + pub(super) fn record_fetch(&self, path: &str, progress: FetchProgress) -> AppResult<()> { + self.completed + .lock() + .map_err(|_| AppError::other("Group results lock poisoned"))? + .insert(path.into(), Completed::Fetch(progress)); + Ok(()) + } + + pub(super) fn fetch_progress(&self, path: &str) -> Option { + self.completed + .lock() + .ok() + .and_then(|completed| match completed.get(path) { + Some(Completed::Fetch(progress)) => Some(progress.clone()), + _ => None, + }) + } + + pub fn with_active_result( + &self, + path: &str, + action: impl FnOnce() -> AppResult, + ) -> AppResult { + self.with_active(|| { + let result = action(); + let receipt = match &result { + Ok(result) => result.clone(), + Err(error) => GroupUpdateResult { + status: "failed".into(), + message: error.to_string(), + changes: Vec::new(), + }, + }; + self.completed + .lock() + .map_err(|_| AppError::other("Group results lock poisoned"))? + .insert(path.into(), Completed::Result(receipt)); + result + }) + } + + pub fn has_completed(&self, path: &str) -> bool { + self.completed + .lock() + .map(|completed| completed.contains_key(path)) + .unwrap_or(false) + } + + pub fn is_cancelled(&self) -> bool { + self.cancelled + .lock() + .map(|cancelled| *cancelled) + .unwrap_or(true) + } + + fn active_guard(&self) -> AppResult> { + let cancelled = self + .cancelled + .lock() + .map_err(|_| AppError::other("Group update lock poisoned"))?; + if *cancelled { + return Err(AppError::other("Group update cancelled")); + } + Ok(cancelled) + } + + pub fn ensure_active(&self) -> AppResult<()> { + drop(self.active_guard()?); + Ok(()) + } + + pub fn with_active(&self, action: impl FnOnce() -> AppResult) -> AppResult { + let _guard = self.active_guard()?; + action() + } +} + +struct Run { + control: Arc, + last_used: Instant, +} + +type Runs = HashMap; +static RUNS: OnceLock> = OnceLock::new(); +const RUN_TTL: Duration = Duration::from_secs(60 * 60); + +fn runs() -> &'static Mutex { + RUNS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn prune_expired(runs: &mut Runs, now: Instant) { + runs.retain(|_, run| { + if now.saturating_duration_since(run.last_used) < RUN_TTL { + return true; + } + match run.control.cancelled.try_lock() { + Ok(mut cancelled) => { + *cancelled = true; + false + } + Err(std::sync::TryLockError::WouldBlock) => true, + Err(std::sync::TryLockError::Poisoned(_)) => false, + } + }); +} + +pub fn start(id: &str) -> AppResult<()> { + let mut runs = runs() + .lock() + .map_err(|_| AppError::other("Group update registry poisoned"))?; + let now = Instant::now(); + prune_expired(&mut runs, now); + if runs.contains_key(id) { + return Err(AppError::other("Group update already exists")); + } + runs.insert( + id.into(), + Run { + control: Arc::new(GroupUpdateControl::default()), + last_used: now, + }, + ); + Ok(()) +} + +pub fn control(id: &str) -> AppResult> { + let mut runs = runs() + .lock() + .map_err(|_| AppError::other("Group update registry poisoned"))?; + let now = Instant::now(); + let control = runs.get_mut(id).map(|run| { + run.last_used = now; + run.control.clone() + }); + prune_expired(&mut runs, now); + control.ok_or_else(|| AppError::other("Group update is no longer active")) +} + +pub fn cancel(id: &str) -> AppResult> { + let mut runs = runs() + .lock() + .map_err(|_| AppError::other("Group update registry poisoned"))?; + let now = Instant::now(); + let control = runs.get_mut(id).map(|run| { + run.last_used = now; + run.control.clone() + }); + prune_expired(&mut runs, now); + drop(runs); + match control { + Some(control) => control.cancel_results(), + None => Ok(Vec::new()), + } +} + +pub fn finish(id: &str) -> AppResult<()> { + let mut runs = runs() + .lock() + .map_err(|_| AppError::other("Group update registry poisoned"))?; + let control = runs.remove(id).map(|run| run.control); + prune_expired(&mut runs, Instant::now()); + drop(runs); + if let Some(control) = control { + control.cancel()?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_runs_are_removed_and_cancel_their_in_flight_downloads() { + let now = Instant::now(); + let control = Arc::new(GroupUpdateControl::default()); + let mut runs = HashMap::from([( + "stale".into(), + Run { + control: control.clone(), + last_used: now - RUN_TTL, + }, + )]); + prune_expired(&mut runs, now); + assert!(runs.is_empty()); + assert!(control.is_cancelled()); + assert!(control + .with_active::<()>(|| panic!("expired run cannot mutate")) + .is_err()); + } + + #[test] + fn expiry_never_blocks_or_interrupts_an_active_checkout() { + let now = Instant::now(); + let control = Arc::new(GroupUpdateControl::default()); + let mut runs = HashMap::from([( + "active".into(), + Run { + control: control.clone(), + last_used: now - RUN_TTL, + }, + )]); + let guard = control.cancelled.lock().unwrap(); + prune_expired(&mut runs, now); + assert_eq!(runs.len(), 1); + assert!(!*guard); + drop(guard); + prune_expired(&mut runs, now); + assert!(runs.is_empty()); + assert!(control.is_cancelled()); + } +} diff --git a/apps/desktop/src-tauri/src/core/mod.rs b/apps/desktop/src-tauri/src/core/mod.rs index 84ceec1b..96d54be3 100644 --- a/apps/desktop/src-tauri/src/core/mod.rs +++ b/apps/desktop/src-tauri/src/core/mod.rs @@ -6,6 +6,7 @@ pub mod commit; pub mod conflict; pub mod diff; pub mod files; +pub mod group_update; pub mod history; pub mod misc; pub mod remote; diff --git a/apps/desktop/src-tauri/src/core/remote.rs b/apps/desktop/src-tauri/src/core/remote.rs index bdad5237..4c30b895 100644 --- a/apps/desktop/src-tauri/src/core/remote.rs +++ b/apps/desktop/src-tauri/src/core/remote.rs @@ -1,8 +1,8 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Mutex, OnceLock, RwLock}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; use git2::{ build::CheckoutBuilder, AutotagOption, Cred, CredentialType, FetchOptions, PushOptions, @@ -11,7 +11,8 @@ use git2::{ use crate::error::{AppError, AppResult}; -use super::types::{GeneratedKey, OpOutcome, RemoteInfo}; +use super::group_update::{FetchProgress, GroupUpdateControl}; +use super::types::{GeneratedKey, GroupUpdateResult, OpOutcome, RefMovement, RemoteInfo}; static CREDENTIAL_PREFS: OnceLock> = OnceLock::new(); @@ -458,6 +459,461 @@ pub fn fetch(path: &str, remote_name: &str, tags: bool, prune: bool) -> AppResul }) } +pub fn group_update(path: &str, pull: bool) -> AppResult { + group_update_controlled(path, pull, &GroupUpdateControl::default()) +} + +pub fn group_update_controlled( + path: &str, + pull: bool, + control: &GroupUpdateControl, +) -> AppResult { + let result = group_update_inner(path, pull, control); + match result { + Ok(result) => { + let _ = control.with_active_result(path, || Ok(result.clone())); + Ok(result) + } + Err(_) if control.is_cancelled() && !control.has_completed(path) => Ok(GroupUpdateResult { + status: "cancelled".into(), + message: "Cancelled before completing the repository update".into(), + changes: Vec::new(), + }), + Err(error) => Err(error), + } +} + +fn group_update_inner( + path: &str, + pull: bool, + control: &GroupUpdateControl, +) -> AppResult { + control.ensure_active()?; + if !Path::new(path).is_dir() { + return Ok(GroupUpdateResult { + status: "skipped".into(), + message: "Repository folder no longer exists".into(), + changes: Vec::new(), + }); + } + let repo = super::repo::open(path)?; + let result = |status: &str, message: String| GroupUpdateResult { + status: status.into(), + message, + changes: Vec::new(), + }; + if !pull { + let names: Vec = repo.remotes()?.iter().flatten().map(String::from).collect(); + if names.is_empty() { + return Ok(result("skipped", "No remotes configured".into())); + } + let mut progress = FetchProgress { + before: Arc::new(fetched_refs(&repo)?), + succeeded: 0, + total: names.len(), + failures: Vec::new(), + }; + for name in &names { + if control.is_cancelled() { + break; + } + match group_fetch(&repo, path, name, control, None, Some(&progress)) { + Ok(_) => progress.succeeded += 1, + Err(error) => { + if !control.is_cancelled() { + progress.failures.push(format!("{name}: {error}")); + } + } + } + if control.is_cancelled() { + if let Some(saved) = control.fetch_progress(path) { + progress = saved; + } + break; + } + control.record_fetch(path, progress.clone())?; + } + return fetch_progress_result(&repo, &progress, control.is_cancelled()); + } + + if repo.is_bare() { + return Ok(result( + "skipped", + "Bare repository has no working tree".into(), + )); + } + if repo.state() != git2::RepositoryState::Clean { + return Ok(result( + "skipped", + "An unfinished Git operation is in progress".into(), + )); + } + if repo.head_detached()? { + return Ok(result("skipped", "HEAD is detached".into())); + } + let head = match repo.head() { + Ok(head) => head, + Err(error) if error.code() == git2::ErrorCode::UnbornBranch => { + return Ok(result("skipped", "Branch has no commits yet".into())); + } + Err(error) => return Err(error.into()), + }; + let refname = head + .name() + .ok_or_else(|| AppError::other("Invalid HEAD name"))? + .to_string(); + let branch_name = head.shorthand().unwrap_or(&refname).to_string(); + let merge_ref = match repo.branch_upstream_merge(&refname) { + Ok(name) => name + .as_str() + .ok_or_else(|| AppError::other("Invalid upstream merge ref"))? + .to_string(), + Err(error) if error.code() == git2::ErrorCode::NotFound => { + return Ok(result( + "skipped", + format!("{branch_name} has no upstream configured"), + )); + } + Err(error) => return Err(error.into()), + }; + let remote = match repo.branch_upstream_remote(&refname) { + Ok(remote) => remote, + Err(error) if error.code() == git2::ErrorCode::NotFound => { + return Ok(result( + "skipped", + format!("{branch_name} has no upstream configured"), + )); + } + Err(error) => return Err(error.into()), + }; + let remote = remote + .as_str() + .ok_or_else(|| AppError::other("Invalid upstream remote"))?; + let tracking_ref = if remote == "." { + Some(merge_ref.clone()) + } else { + match repo.find_remote(remote) { + Ok(_) => match repo.branch_upstream_name(&refname) { + Ok(name) => Some( + name.as_str() + .ok_or_else(|| AppError::other("Invalid upstream name"))? + .to_string(), + ), + Err(error) if error.code() == git2::ErrorCode::NotFound => None, + Err(error) => return Err(error.into()), + }, + Err(error) + if matches!( + error.code(), + git2::ErrorCode::NotFound | git2::ErrorCode::InvalidSpec + ) => + { + None + } + Err(error) => return Err(error.into()), + } + }; + let upstream_ref = tracking_ref.as_deref().unwrap_or(&merge_ref); + let old = head.peel_to_commit()?.id(); + let fetched = if remote != "." { + match group_fetch( + &repo, + path, + remote, + control, + Some((&merge_ref, tracking_ref.as_deref())), + None, + ) { + Ok(oid) => oid, + Err(error) => { + if matches!(&error, AppError::Git(error) if error.code() == git2::ErrorCode::NotFound) + { + return Ok(result( + "skipped", + format!("Configured upstream {upstream_ref} is missing on remote {remote}"), + )); + } + return Err(error); + } + } + } else { + None + }; + control.ensure_active()?; + let target = match fetched { + Some(oid) => repo.find_commit(oid)?, + None => match repo.find_reference(upstream_ref) { + Ok(reference) => reference.peel_to_commit()?, + Err(error) if error.code() == git2::ErrorCode::NotFound => { + return Ok(result( + "skipped", + format!("Configured upstream {upstream_ref} is missing after fetch"), + )); + } + Err(error) => return Err(error.into()), + }, + }; + let new = target.id(); + let (ahead, behind) = repo.graph_ahead_behind(old, new)?; + if behind == 0 { + return Ok(result( + "up_to_date", + if ahead == 0 { + format!("{branch_name} is already up to date") + } else { + format!("{branch_name} is ahead by {ahead} commit(s); nothing to pull") + }, + )); + } + if ahead > 0 { + return Ok(result("skipped", format!("{branch_name} has diverged ({ahead} ahead, {behind} behind); fast-forward unavailable"))); + } + let current = repo.head()?; + if current.name() != Some(refname.as_str()) + || current.target() != Some(old) + || repo.state() != git2::RepositoryState::Clean + { + return Ok(result( + "skipped", + "Repository changed during fetch; try again".into(), + )); + } + let checkout = control.with_active_result(path, || { + let mut checkout = CheckoutBuilder::new(); + checkout.safe(); + if let Err(error) = repo.checkout_tree(target.as_object(), Some(&mut checkout)) { + if error.code() == git2::ErrorCode::Conflict { + return Ok(result( + "skipped", + format!("Local changes prevent checkout: {error}"), + )); + } + return Err(error.into()); + } + repo.find_reference(&refname) + .and_then(|mut reference| { + reference.set_target(new, &format!("pull: fast-forward to {upstream_ref}")) + }) + .map_err(|error| { + AppError::other(format!( + "Checkout completed, but branch update failed for {branch_name}: {error}. \ + The working tree is at {new}; the branch may still point at {old}. \ + Inspect HEAD and working-tree changes before retrying." + )) + })?; + Ok(GroupUpdateResult { + status: "ok".into(), + message: format!("Fast-forwarded {branch_name} by {behind} commit(s)"), + changes: vec![RefMovement { + name: branch_name, + old_oid: Some(old.to_string()), + new_oid: Some(new.to_string()), + commits: Some(behind), + }], + }) + }); + checkout +} + +fn group_fetch( + repo: &Repository, + path: &str, + name: &str, + control: &GroupUpdateControl, + upstream: Option<(&str, Option<&str>)>, + progress: Option<&FetchProgress>, +) -> AppResult> { + control.ensure_active()?; + prime_account_bindings(Some(repo)); + let (mut remote, anonymous) = match repo.find_remote(name) { + Ok(remote) => (remote, false), + Err(error) + if matches!( + error.code(), + git2::ErrorCode::NotFound | git2::ErrorCode::InvalidSpec + ) && upstream.is_some() => + { + (repo.remote_anonymous(name)?, true) + } + Err(error) => return Err(error.into()), + }; + static NEXT_FETCH: AtomicUsize = AtomicUsize::new(0); + let temporary = upstream + .is_some_and(|(_, target)| target.is_none()) + .then(|| { + format!( + "refs/angkorgit/group-pull/{}-{}", + std::process::id(), + NEXT_FETCH.fetch_add(1, Ordering::Relaxed) + ) + }); + let upstream = upstream.map(|(source, target)| { + let target = target + .or(temporary.as_deref()) + .expect("upstream fetch has a destination"); + (source, target) + }); + let mut callbacks = make_callbacks(); + callbacks.transfer_progress(|_| !control.is_cancelled()); + callbacks.sideband_progress(|_| !control.is_cancelled()); + let mut options = FetchOptions::new(); + options.remote_callbacks(callbacks); + let refspecs: Vec = upstream + .map(|(source, target)| format!("+{source}:{target}")) + .into_iter() + .collect(); + remote.download(&refspecs, Some(&mut options))?; + remote.disconnect()?; + let mut apply = || -> AppResult<()> { + let received = AtomicBool::new(upstream.is_none()); + let mut updates = RemoteCallbacks::new(); + updates.update_tips(|name, _, _| { + if upstream.is_some_and(|(_, target)| name == target) { + received.store(true, Ordering::SeqCst); + } + true + }); + remote.update_tips( + Some(&mut updates), + git2::RemoteUpdateFlags::UPDATE_FETCHHEAD | git2::RemoteUpdateFlags::REPORT_UNCHANGED, + AutotagOption::Unspecified, + None, + )?; + if !received.load(Ordering::SeqCst) { + return Err(git2::Error::new( + git2::ErrorCode::NotFound, + git2::ErrorClass::Reference, + "Configured upstream was not received from the remote", + ) + .into()); + } + if !anonymous { + let config = repo.config()?; + let prune = match config.get_bool(&format!("remote.{name}.prune")) { + Ok(prune) => prune, + Err(error) if error.code() == git2::ErrorCode::NotFound => { + config.get_bool("fetch.prune").unwrap_or(false) + } + Err(error) => return Err(error.into()), + }; + if prune { + remote.prune(None)?; + } + } + Ok(()) + }; + control.with_active(|| { + let applied = apply(); + let oid = upstream.and_then(|(_, target)| { + repo.find_reference(target) + .ok() + .and_then(|reference| reference.target()) + }); + if let Some(temporary) = &temporary { + match repo.find_reference(temporary) { + Ok(mut reference) => reference.delete()?, + Err(error) if error.code() == git2::ErrorCode::NotFound => {} + Err(error) => return Err(error.into()), + } + } + if let Some(progress) = progress { + let mut progress = progress.clone(); + match &applied { + Ok(()) => progress.succeeded += 1, + Err(error) => progress.failures.push(format!("{name}: {error}")), + } + control.record_fetch(path, progress)?; + } + applied?; + Ok(oid) + }) +} + +pub(super) fn fetch_progress_result( + repo: &Repository, + progress: &FetchProgress, + cancelled: bool, +) -> AppResult { + let changes = ref_movements(repo, &progress.before)?; + Ok(fetch_progress_summary(progress, cancelled, changes)) +} + +pub(super) fn fetch_progress_summary( + progress: &FetchProgress, + cancelled: bool, + changes: Vec, +) -> GroupUpdateResult { + let status = if progress.succeeded == progress.total { + "ok" + } else if progress.succeeded > 0 || !changes.is_empty() { + "partial" + } else if !progress.failures.is_empty() { + "failed" + } else if cancelled { + "cancelled" + } else { + "failed" + }; + let mut message = format!( + "Fetched {} of {} remotes", + progress.succeeded, progress.total + ); + if cancelled && progress.succeeded < progress.total { + message.push_str(" before cancellation"); + } + if !progress.failures.is_empty() { + message.push_str(&format!(". Failed: {}", progress.failures.join("; "))); + } + GroupUpdateResult { + status: status.into(), + message, + changes, + } +} + +fn ref_movements( + repo: &Repository, + before: &HashMap, +) -> AppResult> { + let after = fetched_refs(repo)?; + let mut keys: Vec<_> = before.keys().chain(after.keys()).cloned().collect(); + keys.sort(); + keys.dedup(); + let changes = keys + .into_iter() + .filter_map(|name| { + let old = before.get(&name).copied(); + let new = after.get(&name).copied(); + if old == new { + return None; + } + Some(RefMovement { + name, + old_oid: old.map(|oid| oid.to_string()), + new_oid: new.map(|oid| oid.to_string()), + commits: old + .zip(new) + .and_then(|(old, new)| repo.graph_ahead_behind(new, old).ok()) + .map(|(ahead, _)| ahead), + }) + }) + .collect(); + Ok(changes) +} + +fn fetched_refs(repo: &Repository) -> AppResult> { + let mut refs = HashMap::new(); + for reference in repo.references()? { + let reference = reference?; + if reference.is_remote() || reference.is_tag() { + if let (Some(name), Some(oid)) = (reference.name(), reference.target()) { + refs.insert(name.to_string(), oid); + } + } + } + Ok(refs) +} + pub fn pull(path: &str, remote_name: &str, mode: Option<&str>) -> AppResult { fetch(path, remote_name, false, false)?; diff --git a/apps/desktop/src-tauri/src/core/types.rs b/apps/desktop/src-tauri/src/core/types.rs index c04c5d2b..025b2b7a 100644 --- a/apps/desktop/src-tauri/src/core/types.rs +++ b/apps/desktop/src-tauri/src/core/types.rs @@ -279,3 +279,26 @@ pub struct GeneratedKey { pub path: String, pub public_key: String, } + +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GroupUpdateResult { + pub status: String, + pub message: String, + pub changes: Vec, +} + +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RefMovement { + pub name: String, + pub old_oid: Option, + pub new_oid: Option, + pub commits: Option, +} + +#[derive(Serialize, Clone)] +pub struct GroupUpdateReceipt { + pub path: String, + pub result: GroupUpdateResult, +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0a4e7b08..5d613deb 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -30,6 +30,10 @@ pub mod test_api { file_diff_with, }; pub use crate::core::files::{file_contents, index_files, tree_files}; + pub use crate::core::group_update::{ + cancel as group_update_cancel, control as group_update_control, + finish as group_update_finish, start as group_update_start, GroupUpdateControl, + }; pub use crate::core::history::{ file_history, list as history, position as history_position, search as history_search, unpushed, @@ -39,7 +43,8 @@ pub mod test_api { tag_delete, tag_list, }; pub use crate::core::remote::{ - add as remote_add, checkout_remote_ref, fetch, list as remote_list, pull, push, + add as remote_add, checkout_remote_ref, fetch, group_update, group_update_controlled, + list as remote_list, pull, push, }; pub use crate::core::repo::{ cleanup_state, discover, info as repo_info, init, ref_fingerprint, set_config, status, @@ -185,6 +190,10 @@ pub fn run() { commands::remote_remove, commands::remote_fetch, commands::remote_pull, + commands::repository_group_update, + commands::repository_group_start, + commands::repository_group_cancel, + commands::repository_group_finish, commands::remote_pull_branch, commands::remote_push, commands::remote_push_tag, diff --git a/apps/desktop/src-tauri/tests/git_engine.rs b/apps/desktop/src-tauri/tests/git_engine.rs index ace69bb3..555b000a 100644 --- a/apps/desktop/src-tauri/tests/git_engine.rs +++ b/apps/desktop/src-tauri/tests/git_engine.rs @@ -22,6 +22,11 @@ impl TempRepo { std::fs::create_dir_all(&dir).unwrap(); let path = dir.to_str().unwrap(); core::init(path).unwrap(); + git2::Repository::open(path) + .unwrap() + .set_head("refs/heads/master") + .unwrap(); + core::set_config(Some(path), "commit.gpgsign", "false", false).unwrap(); core::set_config(Some(path), "user.name", "Test User", false).unwrap(); core::set_config(Some(path), "user.email", "test@angkorgit.dev", false).unwrap(); core::set_config(Some(path), "core.autocrlf", "false", false).unwrap(); @@ -2508,6 +2513,7 @@ fn clone_of(origin: &std::path::Path, local: &TempRepo, suffix: &str) -> TempRep .unwrap(); assert!(status.success()); let clone = TempRepo { dir }; + core::set_config(Some(clone.path()), "commit.gpgsign", "false", false).unwrap(); core::set_config(Some(clone.path()), "user.name", "Other User", false).unwrap(); core::set_config( Some(clone.path()), @@ -3024,3 +3030,850 @@ fn scan_finds_every_repository_and_skips_noise() { assert!(core::scan_repositories(root.join("missing").to_str().unwrap(), 4).is_err()); let _ = std::fs::remove_dir_all(&root); } + +#[test] +fn group_pull_fast_forwards_configured_upstream_without_merging() { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + let old = commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let other = clone_of(&origin, &local, "group"); + let repo = git2::Repository::open(other.path()).unwrap(); + repo.find_branch("master", git2::BranchType::Local) + .unwrap() + .rename("topic", false) + .unwrap(); + core::set_config(Some(other.path()), "pull.rebase", "true", false).unwrap(); + local.write("a.txt", "updated\n"); + let new = commit_all(&local, "updated"); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let result = core::group_update(other.path(), true).unwrap(); + assert_eq!(result.status, "ok"); + assert_eq!(result.changes[0].name, "topic"); + assert_eq!(result.changes[0].old_oid.as_deref(), Some(old.as_str())); + assert_eq!(result.changes[0].new_oid.as_deref(), Some(new.as_str())); + assert_eq!(result.changes[0].commits, Some(1)); + assert_eq!(other.read("a.txt"), "updated\n"); + assert_eq!(head_summary_and_parents(&other).1, 1); + assert_eq!( + core::group_update(other.path(), true).unwrap().status, + "up_to_date" + ); + other.write("mine.txt", "mine\n"); + let mine = commit_all(&other, "mine"); + local.write("a.txt", "remote again\n"); + commit_all(&local, "remote again"); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let result = core::group_update(other.path(), true).unwrap(); + assert_eq!(result.status, "skipped"); + assert!(result.message.contains("diverged")); + assert_eq!(repo.head().unwrap().target().unwrap().to_string(), mine); + assert_eq!(repo.state(), git2::RepositoryState::Clean); + std::fs::remove_dir_all(origin).unwrap(); +} + +#[test] +fn group_pull_skips_conflicting_changes_detached_missing_upstream_and_unfinished_repos() { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + let old = commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let other = clone_of(&origin, &local, "group-skip"); + local.write("a.txt", "remote\n"); + commit_all(&local, "remote"); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + for staged in [false, true] { + other.write("a.txt", "dirty\n"); + if staged { + core::stage_all(other.path()).unwrap(); + } + let result = core::group_update(other.path(), true).unwrap(); + assert_eq!(result.status, "skipped"); + assert!(result.message.contains("Local changes prevent checkout")); + assert_eq!(other.read("a.txt"), "dirty\n"); + } + let repo = git2::Repository::open(other.path()).unwrap(); + repo.reset( + &repo + .find_object(git2::Oid::from_str(&old).unwrap(), None) + .unwrap(), + git2::ResetType::Hard, + None, + ) + .unwrap(); + local.write("incoming.txt", "remote file\n"); + commit_all(&local, "add incoming file"); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + other.write("incoming.txt", "untracked file\n"); + let result = core::group_update(other.path(), true).unwrap(); + assert_eq!(result.status, "skipped"); + assert!(result.message.contains("Local changes prevent checkout")); + assert_eq!(other.read("incoming.txt"), "untracked file\n"); + assert_eq!(repo.head().unwrap().target().unwrap().to_string(), old); + std::fs::remove_file(other.dir.join("incoming.txt")).unwrap(); + std::fs::write(repo.path().join("MERGE_HEAD"), &old).unwrap(); + assert!(core::group_update(other.path(), true) + .unwrap() + .message + .contains("unfinished")); + std::fs::remove_file(repo.path().join("MERGE_HEAD")).unwrap(); + repo.set_head_detached(git2::Oid::from_str(&old).unwrap()) + .unwrap(); + assert!(core::group_update(other.path(), true) + .unwrap() + .message + .contains("detached")); + repo.set_head("refs/heads/master").unwrap(); + repo.find_branch("master", git2::BranchType::Local) + .unwrap() + .set_upstream(None) + .unwrap(); + assert!(core::group_update(other.path(), true) + .unwrap() + .message + .contains("no upstream")); + assert_eq!(repo.head().unwrap().target().unwrap().to_string(), old); + assert_eq!( + core::group_update(other.dir.join("missing").to_str().unwrap(), true) + .unwrap() + .status, + "skipped" + ); + std::fs::remove_dir_all(origin).unwrap(); +} + +#[test] +fn group_fetch_reports_moved_refs_and_continues_after_remote_failure() { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + let old = commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let other = clone_of(&origin, &local, "group-fetch"); + core::remote_add( + other.path(), + "broken", + other.dir.join("missing.git").to_str().unwrap(), + ) + .unwrap(); + local.write("a.txt", "new\n"); + let new = commit_all(&local, "new"); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let result = core::group_update(other.path(), false).unwrap(); + assert_eq!(result.status, "partial"); + assert!(result.message.contains("broken")); + let moved = result + .changes + .iter() + .find(|r| r.name == "refs/remotes/origin/master") + .unwrap(); + assert_eq!(moved.old_oid.as_deref(), Some(old.as_str())); + assert_eq!(moved.new_oid.as_deref(), Some(new.as_str())); + assert_eq!(other.read("a.txt"), "base\n"); + assert_eq!( + git2::Repository::open(other.path()) + .unwrap() + .head() + .unwrap() + .target() + .unwrap() + .to_string(), + old + ); + let unchanged_partial = core::group_update(other.path(), false).unwrap(); + assert_eq!(unchanged_partial.status, "partial"); + assert!(unchanged_partial.changes.is_empty()); + let empty = TempRepo::new(); + assert_eq!( + core::group_update(empty.path(), false).unwrap().status, + "skipped" + ); + assert!(core::group_update(empty.path(), true) + .unwrap() + .message + .contains("no commits")); + std::fs::remove_dir_all(origin).unwrap(); +} + +#[test] +fn group_pull_restores_missing_tracking_refs_and_reports_deleted_upstreams() { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + let old = commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let other = clone_of(&origin, &local, "missing-upstream"); + let repo = git2::Repository::open(other.path()).unwrap(); + repo.find_reference("refs/remotes/origin/master") + .unwrap() + .delete() + .unwrap(); + assert!(repo + .find_branch("master", git2::BranchType::Local) + .unwrap() + .upstream() + .is_err()); + assert_eq!( + core::group_update(other.path(), true).unwrap().status, + "up_to_date" + ); + git2::Repository::open_bare(&origin) + .unwrap() + .find_reference("refs/heads/master") + .unwrap() + .delete() + .unwrap(); + let result = core::group_update(other.path(), true).unwrap(); + assert_eq!(result.status, "skipped"); + assert!( + result + .message + .contains("Configured upstream refs/remotes/origin/master is missing"), + "{}", + result.message + ); + assert!(!result.message.contains("no upstream")); + assert_eq!(repo.head().unwrap().target().unwrap().to_string(), old); + std::fs::remove_dir_all(origin).unwrap(); +} + +#[test] +fn group_pull_matches_toolbar_pull_with_submodule_metadata_and_lfs_attributes() { + for (file, content) in [ + ( + ".gitmodules", + "[submodule \"sub\"]\npath = sub\nurl = ../sub.git\n", + ), + (".gitattributes", "*.txt filter=lfs diff=lfs -text\n"), + ] { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let other = clone_of(&origin, &local, "group-compatible"); + let toolbar = clone_of(&origin, &local, "toolbar-compatible"); + local.write(file, content); + local.write( + "a.txt", + "version https://git-lfs.github.com/spec/v1\noid sha256:123\nsize 1\n", + ); + let new = commit_all(&local, "special checkout"); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + assert_eq!(core::group_update(other.path(), true).unwrap().status, "ok"); + core::pull(toolbar.path(), "origin", Some("merge")).unwrap(); + assert_eq!(other.read("a.txt"), toolbar.read("a.txt")); + assert_eq!(other.read(file), toolbar.read(file)); + assert_eq!( + git2::Repository::open(other.path()) + .unwrap() + .head() + .unwrap() + .target() + .unwrap() + .to_string(), + new + ); + std::fs::remove_dir_all(origin).unwrap(); + } +} + +#[test] +fn group_pull_matches_toolbar_pull_with_active_hooks() { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let other = clone_of(&origin, &local, "group-hooks"); + let toolbar = clone_of(&origin, &local, "toolbar-hooks"); + let repo = git2::Repository::open(other.path()).unwrap(); + let hooks = repo.path().join("custom-hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + for path in [other.path(), toolbar.path()] { + core::set_config(Some(path), "core.hooksPath", hooks.to_str().unwrap(), false).unwrap(); + } + for name in ["post-checkout", "post-merge"] { + let hook = hooks.join(name); + std::fs::write(&hook, "#!/bin/sh\nprintf invoked > hook-ran\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + local.write("a.txt", "updated\n"); + let new = commit_all(&local, "updated"); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + assert_eq!(core::group_update(other.path(), true).unwrap().status, "ok"); + core::pull(toolbar.path(), "origin", Some("merge")).unwrap(); + assert_eq!(other.read("a.txt"), toolbar.read("a.txt")); + assert_eq!(repo.head().unwrap().target().unwrap().to_string(), new); + assert!(!other.dir.join("hook-ran").exists()); + assert!(!toolbar.dir.join("hook-ran").exists()); + std::fs::remove_dir_all(origin).unwrap(); +} + +#[test] +fn group_cancel_prevents_all_future_updates() { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + let old = commit_all(&local, "base"); + let control = core::GroupUpdateControl::default(); + control.ensure_active().unwrap(); + control.cancel().unwrap(); + assert!(control.ensure_active().is_err()); + assert!(control + .with_active::<()>(|| panic!("cancelled action must not run")) + .is_err()); + let result = core::group_update_controlled(local.path(), true, &control).unwrap(); + assert_eq!(result.status, "cancelled"); + assert_eq!( + git2::Repository::open(local.path()) + .unwrap() + .head() + .unwrap() + .target() + .unwrap() + .to_string(), + old + ); +} + +#[test] +fn group_cancel_acknowledges_a_stalled_remote_without_waiting_for_the_socket() { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + let old = commit_all(&local, "base"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + core::remote_add( + local.path(), + "origin", + &format!("http://{}/repo.git", listener.local_addr().unwrap()), + ) + .unwrap(); + let (accepted_tx, accepted_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (socket, _) = listener.accept().unwrap(); + drop(listener); + accepted_tx.send(()).unwrap(); + release_rx + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + drop(socket); + }); + let control = std::sync::Arc::new(core::GroupUpdateControl::default()); + let worker_control = control.clone(); + let path = local.path().to_string(); + let worker = + std::thread::spawn(move || core::group_update_controlled(&path, false, &worker_control)); + accepted_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + let start = std::time::Instant::now(); + control.cancel().unwrap(); + assert!(start.elapsed() < std::time::Duration::from_secs(1)); + assert!(!worker.is_finished()); + release_tx.send(()).unwrap(); + server.join().unwrap(); + assert_eq!(worker.join().unwrap().unwrap().status, "cancelled"); + let repo = git2::Repository::open(local.path()).unwrap(); + assert_eq!(repo.head().unwrap().target().unwrap().to_string(), old); + assert!(repo.find_reference("refs/remotes/origin/master").is_err()); +} + +#[test] +fn group_run_registry_keeps_cancellation_on_in_flight_handles_after_finish() { + let local = TempRepo::new(); + let id = local.path(); + core::group_update_start(id).unwrap(); + assert!(core::group_update_start(id).is_err()); + let control = core::group_update_control(id).unwrap(); + assert!(!control.is_cancelled()); + core::group_update_cancel(id).unwrap(); + assert!(control.is_cancelled()); + core::group_update_finish(id).unwrap(); + assert!(core::group_update_control(id).is_err()); + assert!(control + .with_active::<()>(|| panic!("finished handles cannot mutate")) + .is_err()); + core::group_update_finish(id).unwrap(); +} + +#[test] +fn group_pull_preserves_unrelated_untracked_unstaged_and_staged_changes() { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + local.write("notes.txt", "notes\n"); + commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + for mode in ["untracked", "unstaged", "staged"] { + let other = clone_of(&origin, &local, mode); + other.write("scratch.txt", "untracked notes\n"); + if mode != "untracked" { + other.write("notes.txt", "local notes\n"); + } + if mode == "staged" { + core::stage_file(other.path(), "notes.txt").unwrap(); + } + local.write("a.txt", mode); + let new = commit_all(&local, mode); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + assert_eq!(core::group_update(other.path(), true).unwrap().status, "ok"); + assert_eq!(other.read("scratch.txt"), "untracked notes\n"); + assert_eq!( + other.read("notes.txt"), + if mode == "untracked" { + "notes\n" + } else { + "local notes\n" + } + ); + assert_eq!(other.read("a.txt"), mode); + let repo = git2::Repository::open(other.path()).unwrap(); + assert_eq!(repo.head().unwrap().target().unwrap().to_string(), new); + let status = repo.status_file(std::path::Path::new("notes.txt")).unwrap(); + assert_eq!( + status.contains(git2::Status::INDEX_MODIFIED), + mode == "staged" + ); + assert_eq!( + status.contains(git2::Status::WT_MODIFIED), + mode == "unstaged" + ); + } + std::fs::remove_dir_all(origin).unwrap(); +} + +#[test] +fn group_pull_explains_when_checkout_succeeds_but_branch_update_fails() { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + let old = commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let other = clone_of(&origin, &local, "locked-ref"); + let repo = git2::Repository::open(other.path()).unwrap(); + std::fs::write(repo.path().join("refs/heads/master.lock"), "locked").unwrap(); + local.write("a.txt", "updated\n"); + let new = commit_all(&local, "updated"); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let error = core::group_update(other.path(), true) + .unwrap_err() + .to_string(); + assert!( + error.contains("Checkout completed, but branch update failed"), + "{error}" + ); + assert!(error.contains(&old)); + assert!(error.contains(&new)); + assert_eq!(repo.head().unwrap().target().unwrap().to_string(), old); + assert_eq!(other.read("a.txt"), "updated\n"); + std::fs::remove_dir_all(origin).unwrap(); +} + +#[test] +fn group_fetch_remote_prune_setting_overrides_global_setting() { + for (global, remote, pruned) in [ + (true, Some(false), false), + (false, Some(true), true), + (true, None, true), + (false, None, false), + ] { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let repo = git2::Repository::open(local.path()).unwrap(); + repo.branch( + "obsolete", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + core::push(local.path(), "origin", Some("obsolete"), false, false, true).unwrap(); + let other = clone_of(&origin, &local, "prune"); + core::set_config( + Some(other.path()), + "fetch.prune", + &global.to_string(), + false, + ) + .unwrap(); + if let Some(remote) = remote { + core::set_config( + Some(other.path()), + "remote.origin.prune", + &remote.to_string(), + false, + ) + .unwrap(); + } + git2::Repository::open_bare(&origin) + .unwrap() + .find_reference("refs/heads/obsolete") + .unwrap() + .delete() + .unwrap(); + let outcome = core::group_update(other.path(), false).unwrap(); + assert_eq!(outcome.status, "ok"); + let other_repo = git2::Repository::open(other.path()).unwrap(); + assert_eq!( + other_repo + .find_reference("refs/remotes/origin/obsolete") + .is_err(), + pruned + ); + assert_eq!( + outcome + .changes + .iter() + .any(|change| change.name == "refs/remotes/origin/obsolete" + && change.new_oid.is_none()), + pruned + ); + std::fs::remove_dir_all(origin).unwrap(); + } +} + +#[test] +fn group_cancel_reports_a_completed_checkout_before_acknowledging() { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + let old = commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let other = clone_of(&origin, &local, "cancel-checkout"); + local.write("a.txt", "updated\n"); + let new = commit_all(&local, "updated"); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let control = core::GroupUpdateControl::default(); + let outcome = core::group_update_controlled(other.path(), true, &control).unwrap(); + assert_eq!(outcome.status, "ok"); + let receipts = control.cancel_results().unwrap(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].path, other.path()); + assert_eq!(receipts[0].result.status, "ok"); + assert_eq!( + receipts[0].result.changes[0].old_oid.as_deref(), + Some(old.as_str()) + ); + assert_eq!( + receipts[0].result.changes[0].new_oid.as_deref(), + Some(new.as_str()) + ); + assert_eq!(other.read("a.txt"), "updated\n"); + assert!(control.is_cancelled()); + std::fs::remove_dir_all(origin).unwrap(); +} + +#[test] +fn group_pull_accepts_url_upstreams_without_leaving_temporary_refs() { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let other = clone_of(&origin, &local, "url-upstream"); + core::branch_create(other.path(), "work/topic", None, true).unwrap(); + core::set_config( + Some(other.path()), + "branch.work/topic.merge", + "refs/heads/master", + false, + ) + .unwrap(); + let url = reqwest::Url::from_file_path(&origin).unwrap().to_string(); + core::set_config(Some(other.path()), "branch.work/topic.remote", &url, false).unwrap(); + local.write("a.txt", "updated\n"); + let new = commit_all(&local, "updated"); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let outcome = core::group_update(other.path(), true).unwrap(); + assert_eq!(outcome.status, "ok", "{}", outcome.message); + assert_eq!(other.read("a.txt"), "updated\n"); + let repo = git2::Repository::open(other.path()).unwrap(); + assert_eq!(repo.head().unwrap().target().unwrap().to_string(), new); + assert_eq!( + repo.config() + .unwrap() + .get_string("branch.work/topic.remote") + .unwrap(), + url + ); + assert!(repo + .references_glob("refs/angkorgit/group-pull/*") + .unwrap() + .next() + .is_none()); + assert_eq!( + core::group_update(other.path(), true).unwrap().status, + "up_to_date" + ); + core::set_config( + Some(other.path()), + "branch.work/topic.remote", + origin.to_str().unwrap(), + false, + ) + .unwrap(); + assert_eq!( + core::group_update(other.path(), true).unwrap().status, + "up_to_date" + ); + git2::Repository::open_bare(&origin) + .unwrap() + .find_reference("refs/heads/master") + .unwrap() + .delete() + .unwrap(); + assert_eq!( + core::group_update(other.path(), true).unwrap().status, + "skipped" + ); + assert!(repo + .references_glob("refs/angkorgit/group-pull/*") + .unwrap() + .next() + .is_none()); + std::fs::remove_dir_all(origin).unwrap(); +} + +#[test] +fn group_fetch_cancel_receipts_keep_successful_remotes_and_prior_errors() { + for successful in [true, false] { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let other = clone_of(&origin, &local, "cancel-partial"); + git2::Repository::open(other.path()) + .unwrap() + .remote_delete("origin") + .unwrap(); + if successful { + core::remote_add(other.path(), "a-good", origin.to_str().unwrap()).unwrap(); + } + core::remote_add( + other.path(), + "b-error", + if successful { + origin.to_str().unwrap() + } else { + "/nonexistent/angkorgit-remote.git" + }, + ) + .unwrap(); + if successful { + core::set_config(Some(other.path()), "remote.b-error.prune", "invalid", false).unwrap(); + } + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + core::remote_add( + other.path(), + "c-stalled", + &format!("http://{}/repo.git", listener.local_addr().unwrap()), + ) + .unwrap(); + let (accepted_tx, accepted_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (socket, _) = listener.accept().unwrap(); + drop(listener); + accepted_tx.send(()).unwrap(); + release_rx + .recv_timeout(std::time::Duration::from_secs(30)) + .unwrap(); + drop(socket); + }); + let control = std::sync::Arc::new(core::GroupUpdateControl::default()); + let worker_control = control.clone(); + let path = other.path().to_string(); + let worker = std::thread::spawn(move || { + core::group_update_controlled(&path, false, &worker_control) + }); + accepted_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + let start = std::time::Instant::now(); + let receipts = control.cancel_results().unwrap(); + assert!(start.elapsed() < std::time::Duration::from_secs(5)); + assert!(!worker.is_finished()); + assert_eq!(receipts.len(), 1); + let result = &receipts[0].result; + assert_eq!(result.status, if successful { "partial" } else { "failed" }); + assert!(result.message.contains("b-error"), "{}", result.message); + assert!(result.message.contains("before cancellation")); + assert_eq!( + result + .changes + .iter() + .any(|change| change.name == "refs/remotes/a-good/master"), + successful + ); + release_tx.send(()).unwrap(); + server.join().unwrap(); + let outcome = worker.join().unwrap().unwrap(); + assert_eq!(outcome.status, result.status); + assert_eq!(outcome.message, result.message); + std::fs::remove_dir_all(origin).unwrap(); + } +} + +#[test] +fn group_pull_fetches_an_upstream_outside_the_remote_fetch_refspec() { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + let old = commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let other = clone_of(&origin, &local, "unmapped-upstream"); + let fetch = "+refs/heads/elsewhere:refs/remotes/origin/elsewhere"; + core::set_config(Some(other.path()), "remote.origin.fetch", fetch, false).unwrap(); + let repo = git2::Repository::open(other.path()).unwrap(); + assert_eq!( + repo.branch_upstream_name("refs/heads/master") + .err() + .unwrap() + .code(), + git2::ErrorCode::NotFound + ); + local.write("a.txt", "updated\n"); + let new = commit_all(&local, "updated"); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let outcome = core::group_update(other.path(), true).unwrap(); + assert_eq!(outcome.status, "ok", "{}", outcome.message); + assert_eq!(repo.head().unwrap().target().unwrap().to_string(), new); + assert_eq!(other.read("a.txt"), "updated\n"); + assert_eq!( + repo.config() + .unwrap() + .get_string("remote.origin.fetch") + .unwrap(), + fetch + ); + assert_eq!( + repo.find_reference("refs/remotes/origin/master") + .unwrap() + .target() + .unwrap() + .to_string(), + old + ); + assert!(repo + .references_glob("refs/angkorgit/group-pull/*") + .unwrap() + .next() + .is_none()); + assert_eq!( + core::group_update(other.path(), true).unwrap().status, + "up_to_date" + ); + git2::Repository::open_bare(&origin) + .unwrap() + .find_reference("refs/heads/master") + .unwrap() + .delete() + .unwrap(); + let deleted = core::group_update(other.path(), true).unwrap(); + assert_eq!(deleted.status, "skipped"); + assert!(deleted.message.contains("missing on remote origin")); + assert!(repo + .references_glob("refs/angkorgit/group-pull/*") + .unwrap() + .next() + .is_none()); + std::fs::remove_dir_all(origin).unwrap(); +} + +#[test] +fn group_cancel_preserves_other_receipts_when_a_fetch_repository_path_becomes_invalid() { + let local = TempRepo::new(); + local.write("a.txt", "base\n"); + commit_all(&local, "base"); + let origin = bare_origin(&local); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let other = clone_of(&origin, &local, "moved-fetch"); + local.write("a.txt", "updated\n"); + commit_all(&local, "updated"); + core::push(local.path(), "origin", None, false, false, true).unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + core::remote_add( + other.path(), + "z-stalled", + &format!("http://{}/repo.git", listener.local_addr().unwrap()), + ) + .unwrap(); + let (accepted_tx, accepted_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (socket, _) = listener.accept().unwrap(); + drop(listener); + accepted_tx.send(()).unwrap(); + let _ = release_rx.recv_timeout(std::time::Duration::from_secs(30)); + drop(socket); + }); + let control = std::sync::Arc::new(core::GroupUpdateControl::default()); + let finished = TempRepo::new(); + assert_eq!( + core::group_update_controlled(finished.path(), false, &control) + .unwrap() + .status, + "skipped" + ); + let alias = TempRepo::new(); + std::fs::remove_dir_all(alias.dir.join(".git")).unwrap(); + let gitdir = format!( + "gitdir: {}\n", + other + .dir + .join(".git") + .display() + .to_string() + .replace('\\', "/") + ); + alias.write(".git", &gitdir); + let worker_control = control.clone(); + let path = alias.path().to_string(); + let worker_path = path.clone(); + let worker = std::thread::spawn(move || { + core::group_update_controlled(&worker_path, false, &worker_control) + }); + accepted_rx + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + alias.write(".git", "gitdir: missing-repository\n"); + assert!(git2::Repository::open(alias.path()).is_err()); + let receipts = control.cancel_results().unwrap(); + assert!(control.is_cancelled()); + assert!(!worker.is_finished()); + assert_eq!(receipts.len(), 2); + let unaffected = receipts + .iter() + .find(|receipt| receipt.path == finished.path()) + .unwrap(); + assert_eq!(unaffected.result.status, "skipped"); + let affected = receipts + .iter() + .find(|receipt| receipt.path == path) + .unwrap(); + assert_eq!(affected.result.status, "partial"); + assert!(affected + .result + .message + .contains("Fetched 1 of 2 remotes before cancellation")); + assert!(affected + .result + .message + .contains("Could not inspect moved refs")); + alias.write(".git", &gitdir); + release_tx.send(()).unwrap(); + server.join().unwrap(); + assert_eq!(worker.join().unwrap().unwrap().status, "partial"); + assert_eq!(other.read("a.txt"), "base\n"); + std::fs::remove_dir_all(origin).unwrap(); +} diff --git a/apps/desktop/src/core/ipc.ts b/apps/desktop/src/core/ipc.ts index 11478fec..c8dba047 100644 --- a/apps/desktop/src/core/ipc.ts +++ b/apps/desktop/src/core/ipc.ts @@ -1,4 +1,6 @@ import type { + GroupUpdateResult, + GroupUpdateReceipt, BranchInfo, CliAgentInfo, CliRunRequest, @@ -467,6 +469,25 @@ export const ipc = { } return invoke('remote_fetch', { path, remote, tags, prune }); }, + async groupStart(runId: string): Promise { + if (!isTauri()) return; + return invoke('repository_group_start', { runId }); + }, + async groupCancel(runId: string): Promise { + if (!isTauri()) return []; + return invoke('repository_group_cancel', { runId }); + }, + async groupFinish(runId: string): Promise { + if (!isTauri()) return; + return invoke('repository_group_finish', { runId }); + }, + async groupUpdate(path: string, pull: boolean, runId: string): Promise { + if (!isTauri()) { + await delay(400); + return { status: 'up_to_date', message: pull ? 'main is already up to date (demo)' : 'Fetched origin; no refs moved (demo)', changes: [] }; + } + return invoke('repository_group_update', { path, pull, runId }); + }, async pull(path: string, remote: string, mode?: 'merge' | 'rebase'): Promise { if (!isTauri()) { await delay(400); diff --git a/apps/desktop/src/features/repository/GroupUpdateDialog.tsx b/apps/desktop/src/features/repository/GroupUpdateDialog.tsx new file mode 100644 index 00000000..c83ba415 --- /dev/null +++ b/apps/desktop/src/features/repository/GroupUpdateDialog.tsx @@ -0,0 +1,85 @@ +import { + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Spinner, +} from '@angkorgit/design-system'; +import { shortenHome } from '@/shared/utils'; +import { classify, type GroupResultKind, type GroupRepositoryResult } from './groupUpdate'; + +export interface GroupUpdateProgress { + runId: string; + name: string; + pull: boolean; + total: number; + results: GroupRepositoryResult[]; + running: boolean; + cancelling: boolean; +} + +export function GroupUpdateDialog({ progress, onClose, onCancel }: { + progress: GroupUpdateProgress | null; + onClose: () => void; + onCancel: () => void; +}) { + if (!progress) return null; + const { name, pull, total, results, running, cancelling } = progress; + const counts: Record = { updated: 0, unchanged: 0, skipped: 0, failed: 0, partial: 0, cancelled: 0 }; + for (const result of results) counts[classify(result)]++; + const labels: Record = { + updated: 'Updated', unchanged: pull ? 'Up to date' : 'Unchanged', skipped: 'Skipped', + failed: 'Failed', partial: 'Partial', cancelled: 'Cancelled', + }; + const tones: Record = { + updated: 'success', unchanged: 'neutral', skipped: 'neutral', + failed: 'danger', partial: 'primary', cancelled: 'neutral', + }; + return ( + { if (!open && !running) onClose(); }}> + { if (running) e.preventDefault(); }} + onInteractOutside={(e) => e.preventDefault()} + > + + {pull ? 'Pull all (fast-forward only)' : 'Fetch all'} — {name} + + {pull ? 'Fast-forwards the checked-out branch from its upstream, preserving unrelated local changes.' : 'Fetches every configured remote in the group.'} + + +
+ {running && } + {cancelling ? 'Cancelling…' : running ? `${results.length} of ${total} repositories complete` : `${total} repositories complete`} · {counts.updated} updated · {counts.unchanged} {pull ? 'up to date' : 'unchanged'} · {counts.skipped} skipped · {counts.failed} failed{counts.partial > 0 && ` · ${counts.partial} partial`}{counts.cancelled > 0 && ` · ${counts.cancelled} cancelled`} +
+
+ {results.map((result) => ( +
+
+ {result.repo.name} + {labels[classify(result)]} +
+

{shortenHome(result.repo.path)}

+

{result.message}

+ {result.changes.map((change) => ( +

+ {change.name.replace(/^refs\/(remotes|heads|tags)\//, '')}: {change.oldOid?.slice(0, 7) ?? 'new'} → {change.newOid?.slice(0, 7) ?? 'deleted'} + {change.commits !== null && ` (${change.commits} commit${change.commits === 1 ? '' : 's'})`} +

+ ))} + {!pull && result.status === 'ok' && result.changes.length === 0 &&

No refs moved.

} +
+ ))} +
+ + {running && } + + +
+
+ ); +} diff --git a/apps/desktop/src/features/repository/WelcomePage.tsx b/apps/desktop/src/features/repository/WelcomePage.tsx index c31d43f5..00ad35fe 100644 --- a/apps/desktop/src/features/repository/WelcomePage.tsx +++ b/apps/desktop/src/features/repository/WelcomePage.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { chordText, groupRepos, parseChordId, repoGroupIdFor, type RepoGroup, + type GroupUpdateResult, type RepoGroupDropPosition, } from '@angkorgit/core'; import { useNavigate } from 'react-router-dom'; @@ -16,11 +17,13 @@ import { ChevronRight, Clock, Copy, + RefreshCw, FolderGit2, FolderOpen, FolderSearch, FolderTree, GitBranchPlus, + ArrowDownToLine, MoreHorizontal, Pencil, Search, @@ -59,6 +62,8 @@ import { CloneDialog } from './CloneDialog'; import { RepoShortcutDialog } from './RepoShortcutDialog'; import { RepoGroupDialog } from './RepoGroupDialog'; import { GroupDot, GroupTile, RepoGroupSubmenu } from './RepoGroupMenu'; +import { GroupUpdateDialog, type GroupUpdateProgress } from './GroupUpdateDialog'; +import { updateGroupRepositories } from './groupUpdate'; import { closeRepoGroup, deleteRepoGroup, openRepoGroup } from './groups'; import { ScanRepositoriesDialog, startRepositoryScan } from './ScanRepositoriesDialog'; import { SettingsDialog } from '@/features/settings/SettingsDialog'; @@ -83,6 +88,9 @@ export function WelcomePage() { const groupOf = useSettings((s) => s.repoGroupOf); const setRepoGroup = useSettings((s) => s.setRepoGroup); const moveRepoGroup = useSettings((s) => s.moveRepoGroup); + const busy = useRepo((s) => s.busy); + const groupRun = useRef<{ id: string; controller: AbortController; cancelling: boolean; ready: Promise; completed: Map } | null>(null); + const [groupProgress, setGroupProgress] = useState(null); const [query, setQuery] = useState(''); const [activePath, setActivePath] = useState(null); const [missing, setMissing] = useState>(new Set()); @@ -94,6 +102,65 @@ export function WelcomePage() { const [groupDrop, setGroupDrop] = useState<{ id: string; position: RepoGroupDropPosition } | null>(null); const [version, setVersion] = useState(''); + useEffect(() => () => { + const run = groupRun.current; + if (run) void run.ready.then(() => ipc.groupCancel(run.id)).then((receipts) => { + run.completed = new Map(receipts.map(({ path, result }) => [path, result])); + run.controller.abort(); + }).catch(() => undefined); + }, []); + + async function cancelGroup() { + const run = groupRun.current; + if (!run || run.cancelling) return; + run.cancelling = true; + setGroupProgress((current) => current?.runId === run.id ? { ...current, cancelling: true } : current); + try { + await run.ready; + const receipts = await ipc.groupCancel(run.id); + run.completed = new Map(receipts.map(({ path, result }) => [path, result])); + run.controller.abort(); + } catch (error) { + run.cancelling = false; + setGroupProgress((current) => current?.runId === run.id ? { ...current, cancelling: false } : current); + toast.error(`Could not cancel group: ${(error as { message?: string })?.message ?? error}`); + } + } + + async function updateGroup(group: RepoGroup, repos: RecentRepository[], pull: boolean) { + if (groupRun.current || useRepo.getState().busy || useRepo.getState().opening || repos.length === 0) return; + const id = crypto.randomUUID(); + const run = { id, controller: new AbortController(), cancelling: false, ready: ipc.groupStart(id), completed: new Map() }; + groupRun.current = run; + useRepo.getState().setBusy(pull ? 'Pulling group…' : 'Fetching group…'); + setGroupProgress({ runId: run.id, name: group.name, pull, total: repos.length, results: [], running: true, cancelling: false }); + try { + await run.ready; + await updateGroupRepositories(repos, (path) => ipc.groupUpdate(path, pull, run.id), (result) => { + setGroupProgress((current) => { + if (current?.runId !== run.id) return current; + const existing = current.results.some((r) => r.repo.path === result.repo.path); + return { ...current, results: existing + ? current.results.map((r) => r.repo.path === result.repo.path ? result : r) + : [...current.results, result] }; + }); + }, run.controller.signal, () => run.completed); + if (repos.some((r) => r.path === useRepo.getState().repo?.path)) { + if (run.controller.signal.aborted) void useRepo.getState().refresh().catch(() => undefined); + else await useRepo.getState().refresh(); + } + } catch (error) { + toast.error(`Group update failed: ${(error as { message?: string })?.message ?? error}`); + } finally { + await ipc.groupFinish(run.id).catch(() => undefined); + if (groupRun.current === run) { + groupRun.current = null; + useRepo.getState().setBusy(null); + } + setGroupProgress((current) => current?.runId === run.id ? { ...current, running: false, cancelling: false } : current); + } + } + useEffect(() => { void appVersion() .then(setVersion) @@ -616,6 +683,19 @@ export function WelcomePage() { {groupMenu.group.name} + void updateGroup(groupMenu.group, groupMenu.repos, false)} + > + Fetch all + + void updateGroup(groupMenu.group, groupMenu.repos, true)} + > + Pull all (fast-forward only) + + @@ -671,6 +751,7 @@ export function WelcomePage() { )} + setGroupProgress(null)} onCancel={() => void cancelGroup()} /> void openRepository(path)} /> diff --git a/apps/desktop/src/features/repository/groupUpdate.ts b/apps/desktop/src/features/repository/groupUpdate.ts new file mode 100644 index 00000000..a4698397 --- /dev/null +++ b/apps/desktop/src/features/repository/groupUpdate.ts @@ -0,0 +1,52 @@ +import type { GroupUpdateResult, RecentRepository } from '@angkorgit/core'; + +export interface GroupRepositoryResult extends GroupUpdateResult { + repo: RecentRepository; +} + +export type GroupResultKind = 'updated' | 'unchanged' | 'skipped' | 'failed' | 'partial' | 'cancelled'; + +export function classify(result: GroupUpdateResult): GroupResultKind { + if (result.status === 'cancelled') return 'cancelled'; + if (result.status === 'partial') return 'partial'; + if (result.status === 'failed') return 'failed'; + if (result.status === 'skipped') return 'skipped'; + return result.changes.length > 0 ? 'updated' : 'unchanged'; +} + +export async function updateGroupRepositories( + repos: readonly RecentRepository[], + update: (path: string) => Promise, + onResult: (result: GroupRepositoryResult) => void, + signal?: AbortSignal, + completedOnCancel: () => ReadonlyMap = () => new Map(), +): Promise { + for (const repo of repos) { + const cancelled: GroupUpdateResult = { status: 'cancelled', message: 'Cancelled before this repository started', changes: [] }; + if (signal?.aborted) { + onResult({ ...(completedOnCancel().get(repo.path) ?? cancelled), repo }); + continue; + } + let started = false; + const pending = Promise.resolve().then(() => { + if (signal?.aborted) return cancelled; + started = true; + return update(repo.path); + }).catch((error): GroupUpdateResult => ({ + status: 'failed', message: (error as { message?: string })?.message ?? String(error), changes: [], + })); + const interrupted = { ...cancelled, message: 'Cancelled before completing this repository' }; + let onAbort: (() => void) | undefined; + const aborted = new Promise((resolve) => { + onAbort = () => resolve(completedOnCancel().get(repo.path) ?? (started ? interrupted : cancelled)); + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) onAbort(); + }); + const winner = await Promise.race([ + pending.then((result) => ({ source: 'pending' as const, result })), + aborted.then((result) => ({ source: 'abort' as const, result })), + ]); + if (onAbort) signal?.removeEventListener('abort', onAbort); + onResult({ ...winner.result, repo }); + } +} diff --git a/docs/assets/group-update-cancel-summary.png b/docs/assets/group-update-cancel-summary.png new file mode 100644 index 00000000..0aed2ab7 Binary files /dev/null and b/docs/assets/group-update-cancel-summary.png differ diff --git a/docs/assets/group-update-summary-light.png b/docs/assets/group-update-summary-light.png new file mode 100644 index 00000000..1584f246 Binary files /dev/null and b/docs/assets/group-update-summary-light.png differ diff --git a/docs/assets/group-update-summary.png b/docs/assets/group-update-summary.png new file mode 100644 index 00000000..c669e9df Binary files /dev/null and b/docs/assets/group-update-summary.png differ diff --git a/packages/core/src/git/types.ts b/packages/core/src/git/types.ts index 5c66f9e2..0222c45f 100644 --- a/packages/core/src/git/types.ts +++ b/packages/core/src/git/types.ts @@ -244,3 +244,21 @@ export interface FileBlame { lines: string[]; hunks: BlameHunk[]; } + +export interface GroupUpdateResult { + status: 'ok' | 'up_to_date' | 'skipped' | 'failed' | 'partial' | 'cancelled'; + message: string; + changes: RefMovement[]; +} + +export interface RefMovement { + name: string; + oldOid: string | null; + newOid: string | null; + commits: number | null; +} + +export interface GroupUpdateReceipt { + path: string; + result: GroupUpdateResult; +} diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 0ad2f375..2f936caa 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -2778,6 +2778,21 @@ test('repositories can be grouped on the welcome page and a group opens as tabs' const frontend = page.locator('[data-group-header]', { hasText: 'Frontend' }); await expect(frontend).toBeVisible(); await expect(frontend.getByText('1', { exact: true })).toBeVisible(); + await frontend.click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Fetch all', exact: true }).click(); + await expect(dialog.getByRole('heading', { name: 'Fetch all — Frontend' })).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Done', exact: true })).toBeEnabled(); + await expect(dialog.locator('[data-group-update-results]')).toContainText('temple-ui'); + await expect(dialog).toContainText('0 skipped · 0 failed'); + await dialog.getByRole('button', { name: 'Done', exact: true }).click(); + await frontend.click({ button: 'right' }); + await expect(page.getByRole('menuitem', { name: 'Pull all (fast-forward only)', exact: true }).locator('svg')).toHaveClass(/lucide-arrow-down-to-line/); + await page.getByRole('menuitem', { name: 'Pull all (fast-forward only)', exact: true }).click(); + await expect(dialog.getByRole('heading', { name: 'Pull all (fast-forward only) — Frontend' })).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Done', exact: true })).toBeEnabled(); + await expect(dialog).toContainText('Up to date'); + await dialog.getByRole('button', { name: 'Done', exact: true }).click(); + const other = page.locator('[data-group-header]', { hasText: 'Other' }); await expect(other.getByText('3', { exact: true })).toBeVisible(); @@ -3049,3 +3064,141 @@ test('stepping through changes marks the active one and counts it in the header' await expect(count).toHaveText(/^\d+ of \d+ changes$/); await expect(activeRows().first()).toBeVisible(); }); + + +test('group pull summary shows moved refs, skips, failures and unchanged repositories', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Recent repositories')).toBeVisible({ timeout: 10_000 }); + await page.evaluate(async () => { + const [{ ipc }, { useSettings }, { useRepo }] = await Promise.all([ + import('/src/core/ipc.ts'), import('/src/features/settings/store.ts'), import('/src/features/repository/store.ts'), + ]); + const repos = ['updated', 'skipped', 'failed', 'current'].map((name) => ({ path: `/projects/${name}`, name, lastOpenedAt: 0 })); + useRepo.setState({ recents: repos }); + useSettings.getState().addRepoGroup('Projects', 0, repos.map((r) => r.path)); + ipc.groupUpdate = async (path, pull) => { + if (!pull) throw new Error('Expected fast-forward pull'); + await new Promise((resolve) => setTimeout(resolve, 250)); + if (path.endsWith('/failed')) throw { message: 'Authentication failed' }; + if (path.endsWith('/skipped')) return { status: 'skipped', message: 'main has diverged; fast-forward unavailable', changes: [] }; + if (path.endsWith('/current')) return { status: 'up_to_date', message: 'main is already up to date', changes: [] }; + return { status: 'ok', message: 'Fast-forwarded main by 2 commits', changes: [{ name: 'main', oldOid: '1234567890', newOid: 'abcdef0123', commits: 2 }] }; + }; + }); + await page.locator('[data-group-header]', { hasText: 'Projects' }).click({ button: 'right' }); + await expect(page.getByRole('menuitem', { name: 'Pull all (fast-forward only)', exact: true }).locator('svg')).toHaveClass(/lucide-arrow-down-to-line/); + await page.getByRole('menuitem', { name: 'Pull all (fast-forward only)', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('button', { name: 'Done', exact: true })).toBeDisabled(); + await page.keyboard.press('Escape'); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Done', exact: true })).toBeEnabled(); + await expect(dialog.getByRole('status')).toContainText('1 updated · 1 up to date · 1 skipped · 1 failed'); + await expect(dialog).toContainText('main: 1234567 → abcdef0 (2 commits)'); + await expect(dialog).toContainText('main has diverged'); + await expect(dialog).toContainText('Authentication failed'); + await expect(dialog).toContainText('main is already up to date'); + await expect(dialog.getByText('Updated', { exact: true })).toHaveClass(/text-success/); + await expect(dialog.getByText('Failed', { exact: true })).toHaveClass(/text-danger/); + await expect(dialog).toContainText('preserving unrelated local changes'); + expect(await dialog.locator('[data-group-update-row]').first().evaluate((row) => getComputedStyle(row).borderTopWidth)).toBe('0px'); + expect(await dialog.getByRole('status').evaluate((status) => getComputedStyle(status).whiteSpace)).toBe('nowrap'); + await page.screenshot({ path: '/tmp/angkorgit-group-summary.png' }); + await page.evaluate(async () => { + const { useSettings } = await import('/src/features/settings/store.ts'); + useSettings.getState().setTheme('light'); + }); + await expect(page.locator('html')).toHaveClass(/light/); + await page.screenshot({ path: '/tmp/angkorgit-group-summary-light.png' }); + await dialog.getByRole('button', { name: 'Done', exact: true }).click(); + await expect(dialog).toBeHidden(); +}); + + +test('group fetch classifies partial failures once and cancellation releases a hanging run', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Recent repositories')).toBeVisible({ timeout: 10_000 }); + await page.evaluate(async () => { + const [{ ipc }, { useSettings }, { useRepo }] = await Promise.all([ + import('/src/core/ipc.ts'), import('/src/features/settings/store.ts'), import('/src/features/repository/store.ts'), + ]); + const repos = ['unchanged', 'partial', 'slow', 'remaining'].map((name) => ({ path: `/projects/${name}`, name, lastOpenedAt: 0 })); + useRepo.setState({ recents: repos }); + useSettings.getState().addRepoGroup('Projects', 0, repos.map((r) => r.path)); + const tracker = window as unknown as { __groupCalls: string[]; __finishGroup: () => void }; + tracker.__groupCalls = []; + ipc.groupUpdate = async (path) => { + tracker.__groupCalls.push(path); + if (path.endsWith('/unchanged')) return { status: 'ok', message: 'Fetched origin without ref changes', changes: [] }; + if (path.endsWith('/partial')) return { + status: 'partial', message: 'origin fetched; backup authentication failed', + changes: [{ name: 'origin/main', oldOid: '1234567890', newOid: 'abcdef0123', commits: 2 }], + }; + return new Promise((resolve) => { tracker.__finishGroup = () => resolve({ status: 'cancelled', message: 'Cancelled', changes: [] }); }); + }; + }); + await page.locator('[data-group-header]', { hasText: 'Projects' }).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Fetch all', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toContainText('Partial'); + await expect(dialog.getByText('Unchanged', { exact: true })).toBeVisible(); + await expect(dialog.getByRole('status')).toContainText('0 updated · 1 unchanged · 0 skipped · 0 failed · 1 partial'); + await dialog.getByRole('button', { name: 'Cancel run', exact: true }).click(); + await expect(dialog.getByRole('button', { name: 'Done', exact: true })).toBeEnabled(); + await expect(dialog.getByRole('status')).toContainText('1 partial · 2 cancelled'); + await page.screenshot({ path: '/tmp/angkorgit-group-cancel-summary.png' }); + await page.evaluate(async () => { + const { useRepo } = await import('/src/features/repository/store.ts'); + if (useRepo.getState().busy !== null) throw new Error('Cancellation kept the app busy'); + const tracker = window as unknown as { __groupCalls: string[] }; + if (tracker.__groupCalls.join(',') !== '/projects/unchanged,/projects/partial,/projects/slow') throw new Error('Started a repository after cancellation'); + }); + await dialog.getByRole('button', { name: 'Done', exact: true }).click(); + await expect(dialog).toBeHidden(); + await page.evaluate(() => (window as unknown as { __finishGroup: () => void }).__finishGroup()); + await expect(dialog).toBeHidden(); + await page.locator('[data-group-header]', { hasText: 'Projects' }).click({ button: 'right' }); + await expect(page.getByRole('menuitem', { name: 'Fetch all', exact: true })).toBeEnabled(); +}); + + +test('cancel counts a completed checkout and ignores a delayed inactive-run error', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Recent repositories')).toBeVisible({ timeout: 10_000 }); + await page.evaluate(async () => { + const [{ ipc }, { useSettings }, { useRepo }] = await Promise.all([ + import('/src/core/ipc.ts'), import('/src/features/settings/store.ts'), import('/src/features/repository/store.ts'), + ]); + const repos = ['updated', 'queued'].map((name) => ({ name, path: `/projects/${name}`, lastOpenedAt: 0 })); + useRepo.setState({ recents: repos }); + useSettings.getState().addRepoGroup('Projects', 0, repos.map((r) => r.path)); + const tracker = window as unknown as { __rejectLateGroup: () => void; __groupCalls: number }; + tracker.__groupCalls = 0; + ipc.groupUpdate = async () => { + tracker.__groupCalls++; + return new Promise((_, reject) => { tracker.__rejectLateGroup = () => reject({ message: 'Group update is no longer active' }); }); + }; + ipc.groupCancel = async () => [{ path: repos[0].path, result: { + status: 'ok', message: 'Fast-forwarded main', changes: [{ name: 'main', oldOid: '1234567', newOid: 'abcdef0', commits: 1 }], + } }]; + }); + await page.locator('[data-group-header]', { hasText: 'Projects' }).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Pull all (fast-forward only)', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: 'Cancel run', exact: true }).click(); + await expect(dialog.getByRole('button', { name: 'Done', exact: true })).toBeEnabled(); + await expect(dialog.getByRole('status')).toContainText('1 updated · 0 up to date · 0 skipped · 0 failed · 1 cancelled'); + await expect(dialog.getByText('Updated', { exact: true })).toBeVisible(); + await page.evaluate(async () => { + const tracker = window as unknown as { __rejectLateGroup: () => void; __groupCalls: number }; + if (tracker.__groupCalls !== 1) throw new Error('Queued repository started'); + tracker.__rejectLateGroup(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + await expect(dialog.getByText('Updated', { exact: true })).toBeVisible(); + await expect(dialog.getByText('Group update is no longer active', { exact: true })).toBeHidden(); + await expect(dialog.getByRole('status')).toContainText('1 updated · 0 up to date · 0 skipped · 0 failed · 1 cancelled'); + await dialog.getByRole('button', { name: 'Done', exact: true }).click(); +}); diff --git a/tests/unit/groupUpdate.test.ts b/tests/unit/groupUpdate.test.ts new file mode 100644 index 00000000..2582f879 --- /dev/null +++ b/tests/unit/groupUpdate.test.ts @@ -0,0 +1,94 @@ +import { expect, it, vi } from 'vitest'; +import { classify, updateGroupRepositories, type GroupRepositoryResult } from '../../apps/desktop/src/features/repository/groupUpdate'; + +it('runs in order, preserves partial results, and continues after a repository fails', async () => { + const repos = ['missing', 'updated', 'failed', 'current'].map((name) => ({ name, path: `/${name}`, lastOpenedAt: 0 })); + const results: GroupRepositoryResult[] = []; + const calls: string[] = []; + await updateGroupRepositories(repos, async (path) => { + calls.push(path); + if (path === '/failed') throw { message: 'Authentication failed' }; + return { status: path === '/missing' ? 'skipped' : path === '/current' ? 'up_to_date' : 'ok', message: path, changes: [] }; + }, (result) => results.push(result)); + expect(calls).toEqual(repos.map((r) => r.path)); + expect(results.map((r) => r.status)).toEqual(['skipped', 'ok', 'failed', 'up_to_date']); + expect(results[2].message).toBe('Authentication failed'); + expect(results.map((r) => r.repo)).toEqual(repos); +}); + +it('gives partial success one outcome shared by counters and badges', () => { + const changes = [{ name: 'origin/main', oldOid: 'one', newOid: 'two', commits: 1 }]; + expect(classify({ status: 'partial', message: 'origin fetched, backup failed', changes })).toBe('partial'); + expect(classify({ status: 'partial', message: 'origin unchanged, backup failed', changes: [] })).toBe('partial'); + expect(classify({ status: 'failed', message: 'offline', changes: [] })).toBe('failed'); + expect(classify({ status: 'ok', message: 'fetched', changes })).toBe('updated'); + expect(classify({ status: 'ok', message: 'fetched', changes: [] })).toBe('unchanged'); + expect(classify({ status: 'up_to_date', message: 'current', changes: [] })).toBe('unchanged'); + expect(classify({ status: 'skipped', message: 'dirty', changes: [] })).toBe('skipped'); + expect(classify({ status: 'cancelled', message: 'stopped', changes })).toBe('cancelled'); +}); + +it.each(['resolve', 'reject'] as const)('cancels a hanging operation and ignores a late %s', async (late) => { + const repos = ['slow', 'next'].map((name) => ({ name, path: `/${name}`, lastOpenedAt: 0 })); + const controller = new AbortController(); + let finish!: (result: GroupRepositoryResult) => void; + let fail!: (error: Error) => void; + const pending = new Promise((resolve, reject) => { finish = resolve; fail = reject; }); + const update = vi.fn(() => pending); + const results: GroupRepositoryResult[] = []; + const run = updateGroupRepositories(repos, update, (result) => results.push(result), controller.signal); + await Promise.resolve(); + controller.abort(); + await run; + expect(update).toHaveBeenCalledTimes(1); + expect(results.map((r) => r.status)).toEqual(['cancelled', 'cancelled']); + expect(results.map((r) => r.message)).toEqual(['Cancelled before completing this repository', 'Cancelled before this repository started']); + if (late === 'resolve') finish({ repo: repos[0], status: 'ok', message: 'Late response', changes: [] }); + else fail(new Error('Group update is no longer active')); + await pending.catch(() => undefined); + await Promise.resolve(); + await Promise.resolve(); + expect(results).toHaveLength(2); + expect(results.map((r) => r.status)).toEqual(['cancelled', 'cancelled']); +}); + +it('uses the cancel acknowledgement for a completed checkout whose response is delayed', async () => { + const repos = ['updated', 'next'].map((name) => ({ name, path: `/${name}`, lastOpenedAt: 0 })); + const controller = new AbortController(); + const completed = new Map(); + const results: GroupRepositoryResult[] = []; + const update = vi.fn(() => new Promise(() => undefined)); + const run = updateGroupRepositories(repos, update, (result) => results.push(result), controller.signal, () => completed); + await Promise.resolve(); + completed.set('/updated', { repo: repos[0], status: 'ok', message: 'Fast-forwarded main', changes: [{ name: 'main', oldOid: 'one', newOid: 'two', commits: 1 }] }); + controller.abort(); + await run; + expect(update).toHaveBeenCalledTimes(1); + expect(results.map((r) => classify(r))).toEqual(['updated', 'cancelled']); + expect(results).toHaveLength(2); +}); + +it('does not start any operation when already cancelled', async () => { + const controller = new AbortController(); + controller.abort(); + const update = vi.fn(); + const results: GroupRepositoryResult[] = []; + await updateGroupRepositories([{ name: 'repo', path: '/repo', lastOpenedAt: 0 }], update, (r) => results.push(r), controller.signal); + expect(update).not.toHaveBeenCalled(); + expect(results[0].status).toBe('cancelled'); + expect(results[0].message).toBe('Cancelled before this repository started'); +}); + +it('delivers one cancellation when aborted before the pending microtask starts', async () => { + const controller = new AbortController(); + const update = vi.fn(); + const onResult = vi.fn(); + const run = updateGroupRepositories([{ name: 'repo', path: '/repo', lastOpenedAt: 0 }], update, onResult, controller.signal); + controller.abort(); + await run; + await Promise.resolve(); + expect(update).not.toHaveBeenCalled(); + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult.mock.calls[0][0].status).toBe('cancelled'); + expect(onResult.mock.calls[0][0].message).toBe('Cancelled before this repository started'); +});