From 8da9961a957460d8baeb80c90ac7f140b6d87fa2 Mon Sep 17 00:00:00 2001 From: cheat2001 Date: Tue, 22 Sep 2026 21:30:58 +0700 Subject: [PATCH 1/3] fix(inspector): use an open folder icon for the All files view --- CLAUDE.md | 3 ++- apps/desktop/src/features/inspector/Inspector.tsx | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3cf39d51..e5f11325 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1399,7 +1399,8 @@ features/ │ like GitKraken"): ui.fileView 'list' | 'tree' | 'all' │ (persisted; the persist merge maps the old boolean fileTree │ → 'tree'), three aria-pressed header buttons List/FolderTree/ -│ Files. 'all' renders EVERY file — commit: ipc.treeFiles +│ FolderOpen (lucide `Files` was tried first and read as a +│ copy icon, owner 2026-09-22). 'all' renders EVERY file — commit: ipc.treeFiles │ (engine files.rs) unioned with the change list via core │ allFiles (deleted files stay, keyed by path); working copy: │ ipc.indexFiles ∪ status paths, reloaded on statusVersion diff --git a/apps/desktop/src/features/inspector/Inspector.tsx b/apps/desktop/src/features/inspector/Inspector.tsx index ddfee49a..9e23e890 100644 --- a/apps/desktop/src/features/inspector/Inspector.tsx +++ b/apps/desktop/src/features/inspector/Inspector.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import type { CommitFileInfo, CommitInfo } from '@angkorgit/core'; -import { Files, FolderTree, List, Search, X } from 'lucide-react'; +import { FolderOpen, FolderTree, List, Search, X } from 'lucide-react'; import { Hint, Button, cn } from '@angkorgit/design-system'; import { useGraph } from '@/features/graph/store'; import { useRepo } from '@/features/repository/store'; @@ -129,7 +129,7 @@ export function Inspector() { className={cn(fileView === 'all' && 'bg-surface-raised text-foreground')} onClick={() => setFileView('all')} > - + {(commit || commitError) && ( From 96de163b19c8390b7a679011ab60e6207985fd0f Mon Sep 17 00:00:00 2001 From: noyobo Date: Sun, 20 Sep 2026 22:20:01 +0800 Subject: [PATCH 2/3] fix(remote): send named tag refspecs instead of refs/tags/* libgit2 Remote::push looks up the glob as a literal ref. Enumerate local tags and push refs/tags/{name}:refs/tags/{name} so Push with tags works. --- CHANGELOG.md | 2 ++ apps/desktop/src-tauri/src/core/remote.rs | 37 ++++++++++++++++------ apps/desktop/src-tauri/tests/git_engine.rs | 21 ++++++++++++ 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df0b5610..c6449676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ All notable changes to AngKorGit are documented here. The format follows to fall apart once the view scrolled: copy returned one line and the highlight moved when you scrolled back. The selection now grows to the last visible line while you hold the mouse below the diff, and copy returns every selected line. +- **Push with tags failed** with `not a valid reference 'refs/tags/*'`. libgit2 does not + expand the glob git uses, so the engine now names each local tag in its own refspec. (#34) ## [0.16.0] — 2026-09-20 diff --git a/apps/desktop/src-tauri/src/core/remote.rs b/apps/desktop/src-tauri/src/core/remote.rs index a8a277c5..97fd1ab8 100644 --- a/apps/desktop/src-tauri/src/core/remote.rs +++ b/apps/desktop/src-tauri/src/core/remote.rs @@ -511,11 +511,25 @@ fn pull_rebase_configured(repo: &Repository) -> bool { .unwrap_or(false) } -pub(crate) fn push_refspecs(branch: &str, force: bool, with_tags: bool) -> Vec { +fn local_tag_names(repo: &Repository) -> AppResult> { + let mut names = Vec::new(); + repo.tag_foreach(|_oid, name_bytes| { + let full = String::from_utf8_lossy(name_bytes); + if let Some(name) = full.strip_prefix("refs/tags/") { + if !name.is_empty() && !name.contains(':') && !name.contains('\0') { + names.push(name.to_string()); + } + } + true + })?; + Ok(names) +} + +pub(crate) fn push_refspecs(branch: &str, force: bool, tags: &[String]) -> Vec { let prefix = if force { "+" } else { "" }; let mut refspecs = vec![format!("{prefix}refs/heads/{branch}:refs/heads/{branch}")]; - if with_tags { - refspecs.push("refs/tags/*:refs/tags/*".to_string()); + for tag in tags { + refspecs.push(format!("refs/tags/{tag}:refs/tags/{tag}")); } refspecs } @@ -538,7 +552,12 @@ pub fn push( .to_string(), }; - let refspecs = push_refspecs(&branch_name, force, with_tags); + let tags = if with_tags { + local_tag_names(&repo)? + } else { + Vec::new() + }; + let refspecs = push_refspecs(&branch_name, force, &tags); let track_upstream = |repo: &Repository| -> AppResult<()> { if set_upstream { @@ -916,10 +935,10 @@ mod tests { #[test] fn plain_push_refspecs_have_no_force_prefix() { assert_eq!( - push_refspecs("main", false, true), + push_refspecs("main", false, &["v1".into()]), vec![ "refs/heads/main:refs/heads/main".to_string(), - "refs/tags/*:refs/tags/*".to_string(), + "refs/tags/v1:refs/tags/v1".to_string(), ] ); } @@ -927,10 +946,10 @@ mod tests { #[test] fn force_push_forces_only_the_branch_refspec() { assert_eq!( - push_refspecs("main", true, true), + push_refspecs("main", true, &["v1".into()]), vec![ "+refs/heads/main:refs/heads/main".to_string(), - "refs/tags/*:refs/tags/*".to_string(), + "refs/tags/v1:refs/tags/v1".to_string(), ] ); } @@ -938,7 +957,7 @@ mod tests { #[test] fn push_without_tags_sends_only_the_branch() { assert_eq!( - push_refspecs("feature/x", true, false), + push_refspecs("feature/x", true, &[]), vec!["+refs/heads/feature/x:refs/heads/feature/x".to_string()] ); } diff --git a/apps/desktop/src-tauri/tests/git_engine.rs b/apps/desktop/src-tauri/tests/git_engine.rs index 4903edc1..1f1f56eb 100644 --- a/apps/desktop/src-tauri/tests/git_engine.rs +++ b/apps/desktop/src-tauri/tests/git_engine.rs @@ -2324,6 +2324,27 @@ fn push_reports_up_to_date_instead_of_pushing_again() { let _ = std::fs::remove_dir_all(&origin); } +#[test] +fn push_with_tags_sends_named_tag_refs() { + let local = TempRepo::new(); + local.write("a.txt", "one\n"); + commit_all(&local, "one"); + let origin = bare_origin(&local); + + core::tag_create(local.path(), "v1.0.0", None, None).unwrap(); + let outcome = core::push(local.path(), "origin", None, false, true, true).unwrap(); + assert_eq!(outcome.status, "ok"); + + let listed = Command::new("git") + .args(["tag", "-l", "v1.0.0"]) + .current_dir(&origin) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&listed.stdout).trim(), "v1.0.0"); + + let _ = std::fs::remove_dir_all(&origin); +} + fn clone_of(origin: &std::path::Path, local: &TempRepo, suffix: &str) -> TempRepo { let dir = local.dir.with_file_name(format!( "{}-{suffix}", From 9707d4d5c77a6c83858c1080761b34e7ce422df4 Mon Sep 17 00:00:00 2001 From: Christian Lauinger Date: Tue, 22 Sep 2026 23:14:49 +0700 Subject: [PATCH 3/3] feat(remote): fetch every remote and report partial failures A failing remote used to stop the loop, so the remotes after it were never fetched. Every remote is tried on each run, the fetched time updates when any succeeds, and the failed names show as a faint status bar hint. Pull marks the repository fetched again. --- CHANGELOG.md | 3 + CLAUDE.md | 14 ++- .../desktop/src/components/CommandPalette.tsx | 11 +- apps/desktop/src/components/StatusBar.tsx | 9 ++ apps/desktop/src/components/Toolbar.tsx | 39 ++++--- .../features/repository/RepositoryPage.tsx | 20 ++-- .../src/features/repository/fetchRemotes.ts | 28 +++++ apps/desktop/src/features/repository/store.ts | 8 +- apps/desktop/src/shared/toastOutcome.ts | 3 +- tests/e2e/smoke.spec.ts | 107 ++++++++++++++++++ tests/unit/fetchRemotes.test.ts | 26 +++++ 11 files changed, 237 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/src/features/repository/fetchRemotes.ts create mode 100644 tests/unit/fetchRemotes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c6449676..2a493eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ All notable changes to AngKorGit are documented here. The format follows the mouse below the diff, and copy returns every selected line. - **Push with tags failed** with `not a valid reference 'refs/tags/*'`. libgit2 does not expand the glob git uses, so the engine now names each local tag in its own refspec. (#34) +- **Fetch keeps checking every remote.** A failing remote no longer prevents the others + from being fetched on later automatic runs. Partial results name the failed remotes, + and the status bar keeps showing when a successful fetch or pull last happened. ## [0.16.0] — 2026-09-20 diff --git a/CLAUDE.md b/CLAUDE.md index e5f11325..b4deb2f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -592,7 +592,10 @@ components/ ← RepoTabs (tab strip is overflow-x-auto with th reset to null on open/close so a tab switch shows the fresh fetch; a 30s interval re-renders timeAgo; the Hint names the exact time and the auto-fetch cadence, or says - auto fetch is off; AI CHIP (issue #23, trimmed from the + auto fetch is off; failed remotes get a separate faint + "Fetch incomplete" hint naming only the remote names, + without hiding the last successful fetch time; AI CHIP + (issue #23, trimmed from the proposal: no last-used time): ALWAYS visible (owner 2026-09-13, after a hide-until-tested version: "keep display there, when not configured clicking opens the config"), @@ -649,7 +652,10 @@ features/ │ │ per-repo state reset, RepoLoadingOverlay: content-area │ │ bg-background/95 blur + Logo draw loop + repo name while │ │ opening/refreshing — shows only after 250ms, stays ≥450ms -│ │ so fast switches never flash it), CloneDialog, RepoDialogs +│ │ so fast switches never flash it; auto fetch tries every +│ │ remote on each tick, marks fetched when any succeeds, +│ │ records failed remote names, and retries at the configured +│ │ interval), CloneDialog, RepoDialogs │ │ (create branch/tag/stash — the stash dialog takes an │ │ optional StashPreset {paths} context (working-copy row menu │ │ "Stash this file…" / "Stash n files…"), lists the files name-first and passes @@ -2508,8 +2514,8 @@ update CLAUDE.md or docs/ — never the code. | --- | --- | --- | | Rust integration (84) | `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, 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 | | Rust module (66) | `apps/desktop/src-tauri/src/cli.rs` (7), `src/editors.rs` (4), `src/fonts.rs` (1), `src/ai_cli.rs` (7), `src/error.rs` (6), `src/core/remote.rs` (15), `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, stdout capture via fake agent script, {OUTPUT_FILE} substitution, kill-on-timeout · 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) · 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) · 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 (181) | `tests/unit/*.test.ts` | 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, 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), 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), 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) | -| E2E (86) | `tests/e2e/smoke.spec.ts` | 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, 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), 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 and ↓/↑/← walk files and return 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 keeps Edit message disabled 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 — all on demo mode | +| Unit (182) | `tests/unit/*.test.ts` | 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, 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), 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), 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) | +| E2E (88) | `tests/e2e/smoke.spec.ts` | 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), 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 and ↓/↑/← walk files and return 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 keeps Edit message disabled 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 — all on demo mode | ## 9.5 Open-source & community files diff --git a/apps/desktop/src/components/CommandPalette.tsx b/apps/desktop/src/components/CommandPalette.tsx index 74cec79d..1c564c98 100644 --- a/apps/desktop/src/components/CommandPalette.tsx +++ b/apps/desktop/src/components/CommandPalette.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { Command } from 'cmdk'; import { toast } from 'sonner'; import { toastOutcome } from '@/shared/toastOutcome'; +import { fetchRemotes, fetchResultMessage } from '@/features/repository/fetchRemotes'; import { Archive, ArchiveRestore, @@ -142,7 +143,7 @@ export function CommandPalette({ onRefresh }: { onRefresh: () => Promise } void (async () => { try { const result = (await op()) as { status?: string; message?: string } | undefined; - if (label === 'Fetch' || label.startsWith('Pull')) useRepo.getState().markFetched(); + if (label.startsWith('Pull') && useRepo.getState().repo?.path === path) useRepo.getState().markFetched(); toastOutcome(result, `${label} done`); await onRefresh(); } catch (error) { @@ -335,7 +336,13 @@ export function CommandPalette({ onRefresh }: { onRefresh: () => Promise } label={remotes.length > 1 ? 'Fetch all remotes (with tags)' : 'Fetch (with tags)'} onSelect={() => run('Fetch', async () => { - for (const r of remotes.length > 0 ? remotes : [{ name: remote }]) await ipc.fetch(path, r.name, true, true); + const result = await fetchRemotes(remotes.map((r) => r.name), (name) => ipc.fetch(path, name, true, true)); + const message = fetchResultMessage(result); + if (useRepo.getState().repo?.path === path) { + if (result.succeeded.length > 0) useRepo.getState().markFetched(); + useRepo.getState().setFetchFailures(result.failed.map(({ name }) => name)); + } + return { status: result.succeeded.length === 0 ? 'failed' : result.failed.length > 0 ? 'partial' : 'ok', message }; }) } /> diff --git a/apps/desktop/src/components/StatusBar.tsx b/apps/desktop/src/components/StatusBar.tsx index 621eba40..959cbc5f 100644 --- a/apps/desktop/src/components/StatusBar.tsx +++ b/apps/desktop/src/components/StatusBar.tsx @@ -24,6 +24,7 @@ export function StatusBar() { const status = useRepo((s) => s.status); const remotes = useRepo((s) => s.remotes); const lastFetchAt = useRepo((s) => s.lastFetchAt); + const fetchFailures = useRepo((s) => s.fetchFailures); const autoFetchMinutes = useSettings((s) => s.autoFetchMinutes); const [, tickClock] = useState(0); useEffect(() => { @@ -115,6 +116,14 @@ export function StatusBar() { )} + {fetchFailures.length > 0 && ( + + + + Fetch incomplete + + + )} 0 && 'text-primary')}> {changes > 0 ? : } {changes > 0 ? `${changes} change${changes === 1 ? '' : 's'}` : 'Clean'} diff --git a/apps/desktop/src/components/Toolbar.tsx b/apps/desktop/src/components/Toolbar.tsx index db5661f0..ac6a0f0b 100644 --- a/apps/desktop/src/components/Toolbar.tsx +++ b/apps/desktop/src/components/Toolbar.tsx @@ -46,6 +46,7 @@ import { import { ipc, pickDirectory } from '@/core/ipc'; import { confirmDialog } from '@/components/confirm'; import { useRepo } from '@/features/repository/store'; +import { fetchRemotes, fetchResultMessage } from '@/features/repository/fetchRemotes'; import { abortMergeFlow } from '@/features/repository/merge'; import { sidebarVisible, useUi } from '@/features/ui/store'; import { useUndo } from '@/features/history/undoStore'; @@ -388,7 +389,7 @@ export function Toolbar({ onRefresh }: { onRefresh: () => Promise }) { setBusy(label); try { const outcome = await op(); - if (label === 'Fetch' || label.startsWith('Pull')) useRepo.getState().markFetched(); + if (label.startsWith('Pull') && useRepo.getState().repo?.path === repo.path) useRepo.getState().markFetched(); if (outcome && 'message' in outcome) { toastOutcome(outcome, `${label} complete`); } else { @@ -442,20 +443,28 @@ export function Toolbar({ onRefresh }: { onRefresh: () => Promise }) { - 1 ? `Fetch all remotes (${remotes.map((r) => r.name).join(', ')})` : `Fetch ${remote}`}> - + 1 ? `Fetch all remotes (${remotes.map((r) => r.name).join(', ')})` : `Fetch ${remote}`}> + + +
diff --git a/apps/desktop/src/features/repository/RepositoryPage.tsx b/apps/desktop/src/features/repository/RepositoryPage.tsx index b4bf424b..6b24599c 100644 --- a/apps/desktop/src/features/repository/RepositoryPage.tsx +++ b/apps/desktop/src/features/repository/RepositoryPage.tsx @@ -4,6 +4,7 @@ import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from import { motion } from 'framer-motion'; import { toast } from 'sonner'; import { useRepo } from './store'; +import { fetchRemotes } from './fetchRemotes'; import { useGraph } from '@/features/graph/store'; import { sidebarVisible, useUi } from '@/features/ui/store'; import { RepoTabs } from '@/components/RepoTabs'; @@ -196,21 +197,23 @@ export function RepositoryPage() { useEffect(() => { if (!repoPath || !autoFetchMinutes || !autoFetchRemotes) return; let fetching = false; - let lastFetch = 0; + let cancelled = false; + let lastAttemptAt = 0; const tick = async () => { - if (fetching || document.hidden) return; - if (Date.now() - lastFetch < 30_000) return; + if (fetching || cancelled || document.hidden) return; const state = useRepo.getState(); if (state.busy || state.repo?.path !== repoPath) return; + if (Date.now() - Math.max(lastAttemptAt, state.lastFetchAt ?? 0) < 30_000) return; const remoteNames = state.remotes.map((remote) => remote.name); if (remoteNames.length === 0) return; fetching = true; - lastFetch = Date.now(); + lastAttemptAt = Date.now(); try { - for (const remote of remoteNames) await ipc.fetch(repoPath, remote, true, false); - if (useRepo.getState().repo?.path === repoPath) useRepo.getState().markFetched(); - } catch { - lastFetch = Date.now() + 4 * 60_000; + const result = await fetchRemotes(remoteNames, (name) => ipc.fetch(repoPath, name, true, false)); + if (!cancelled && useRepo.getState().repo?.path === repoPath) { + if (result.succeeded.length > 0) useRepo.getState().markFetched(); + useRepo.getState().setFetchFailures(result.failed.map(({ name }) => name)); + } } finally { fetching = false; } @@ -220,6 +223,7 @@ export function RepositoryPage() { window.addEventListener('focus', onFocus); void tick(); return () => { + cancelled = true; window.clearInterval(id); window.removeEventListener('focus', onFocus); }; diff --git a/apps/desktop/src/features/repository/fetchRemotes.ts b/apps/desktop/src/features/repository/fetchRemotes.ts new file mode 100644 index 00000000..cd106c89 --- /dev/null +++ b/apps/desktop/src/features/repository/fetchRemotes.ts @@ -0,0 +1,28 @@ +export interface FetchResult { + succeeded: string[]; + failed: { name: string; error: string }[]; +} + +export async function fetchRemotes( + names: string[], + fetchOne: (name: string) => Promise, +): Promise { + const result: FetchResult = { succeeded: [], failed: [] }; + for (const name of names) { + try { + await fetchOne(name); + result.succeeded.push(name); + } catch (error) { + result.failed.push({ name, error: (error as { message?: string })?.message ?? String(error) }); + } + } + return result; +} + +export function fetchResultMessage(result: FetchResult): string { + const total = result.succeeded.length + result.failed.length; + if (total === 0) return 'No remotes configured'; + if (result.failed.length === 0) return total === 1 ? 'Fetched 1 remote' : `Fetched all ${total} remotes`; + const failures = result.failed.map(({ name, error }) => `${name}: ${error}`).join('; '); + return `Fetched ${result.succeeded.length} of ${total} remotes. Failed: ${failures}`; +} diff --git a/apps/desktop/src/features/repository/store.ts b/apps/desktop/src/features/repository/store.ts index 3bdc02a8..36f77e30 100644 --- a/apps/desktop/src/features/repository/store.ts +++ b/apps/desktop/src/features/repository/store.ts @@ -30,6 +30,7 @@ interface RepoState { refreshing: boolean; profileId: string | null; lastFetchAt: number | null; + fetchFailures: string[]; loadRecents: () => Promise; open: (path: string) => Promise; @@ -39,6 +40,7 @@ interface RepoState { setBusy: (label: string | null) => void; setProfileId: (profileId: string | null) => void; markFetched: () => void; + setFetchFailures: (names: string[]) => void; } let openSeq = 0; @@ -63,6 +65,7 @@ export const useRepo = create((set, get) => ({ refreshing: false, profileId: null, lastFetchAt: null, + fetchFailures: [], loadRecents: async () => { const recents = await ipc.recentRepositories(); @@ -86,6 +89,7 @@ export const useRepo = create((set, get) => ({ repo, profileId: null, lastFetchAt: null, + fetchFailures: [], opening: null, refreshing: true, status: null, @@ -99,7 +103,7 @@ export const useRepo = create((set, get) => ({ unpushed: [], }); } else { - set({ repo, profileId: null, lastFetchAt: null, opening: null, refreshing: true }); + set({ repo, profileId: null, lastFetchAt: null, fetchFailures: [], opening: null, refreshing: true }); } void ipc .configGet(repo.path, 'angkorgit.profile') @@ -136,6 +140,7 @@ export const useRepo = create((set, get) => ({ refreshing: false, profileId: null, lastFetchAt: null, + fetchFailures: [], }), refresh: async () => { @@ -189,5 +194,6 @@ export const useRepo = create((set, get) => ({ setBusy: (busy) => set({ busy }), markFetched: () => set({ lastFetchAt: Date.now() }), + setFetchFailures: (fetchFailures) => set({ fetchFailures }), setProfileId: (profileId) => set({ profileId }), })); diff --git a/apps/desktop/src/shared/toastOutcome.ts b/apps/desktop/src/shared/toastOutcome.ts index 91a7569a..5cfb97aa 100644 --- a/apps/desktop/src/shared/toastOutcome.ts +++ b/apps/desktop/src/shared/toastOutcome.ts @@ -4,7 +4,8 @@ export function toastOutcome( outcome: { status?: string; message?: string } | undefined, fallback: string, ): void { - if (outcome?.status === 'conflicts') toast.warning(outcome.message ?? fallback); + if (outcome?.status === 'failed') toast.error(outcome.message ?? fallback); + else if (outcome?.status === 'conflicts' || outcome?.status === 'partial') toast.warning(outcome.message ?? fallback); else if (outcome?.status === 'up_to_date') toast.info(outcome.message ?? fallback); else toast.success(outcome?.message ?? fallback); } diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 07717707..41220dc6 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -1412,6 +1412,113 @@ test('the status bar says when the repository was last fetched', async ({ page } await expect(page.locator('[data-last-fetch]')).toHaveText(/Fetched just now/); }); +test('auto fetch tries all remotes again after a partial failure', async ({ page }) => { + await page.goto('/'); + await page.getByText('angkorgit', { exact: true }).first().click(); + await expect(page.locator('[data-last-fetch]')).toHaveText(/Fetched just now/); + await page.clock.install(); + + await page.evaluate(async () => { + const [{ ipc }, { useRepo }] = await Promise.all([ + import('/src/core/ipc.ts'), + import('/src/features/repository/store.ts'), + ]); + const tracker = window as unknown as { __autoFetchCalls: string[] }; + tracker.__autoFetchCalls = []; + ipc.fetch = async (_path, name) => { + tracker.__autoFetchCalls.push(name); + if (name === 'upstream') throw new Error('unavailable'); + return { status: 'ok', message: 'Fetched origin' }; + }; + useRepo.setState({ + lastFetchAt: null, + remotes: [ + { name: 'origin', url: 'git@github.com:demo/angkorgit.git' }, + { name: 'upstream', url: 'git@github.com:demo/upstream.git' }, + ], + }); + }); + + const calls = () => page.evaluate(() => (window as unknown as { __autoFetchCalls: string[] }).__autoFetchCalls); + await expect.poll(calls).toEqual(['origin', 'upstream']); + await expect(page.locator('[data-last-fetch]')).toHaveText(/Fetched just now/); + await expect(page.locator('[data-fetch-status]')).toHaveText(/Fetch incomplete/); + + await page.clock.runFor(61_000); + await expect.poll(calls).toEqual(['origin', 'upstream', 'origin', 'upstream']); +}); + +test('partial fetches keep the timestamp and name failed remotes without raw errors', async ({ page }) => { + await page.goto('/'); + await page.getByText('angkorgit', { exact: true }).first().click(); + await expect(page.locator('[data-last-fetch]')).toHaveText(/Fetched just now/); + + await page.evaluate(async () => { + const [{ ipc }, { useRepo }, { useSettings }] = await Promise.all([ + import('/src/core/ipc.ts'), + import('/src/features/repository/store.ts'), + import('/src/features/settings/store.ts'), + ]); + useSettings.getState().setAutoFetchMinutes(0); + ipc.remotes = async () => [ + { name: 'origin', url: 'git@github.com:demo/angkorgit.git' }, + { name: 'upstream', url: 'git@github.com:demo/upstream.git' }, + ]; + ipc.fetch = async (_path, name) => { + if (name === 'upstream') throw new Error('private token expired'); + return { status: 'ok', message: 'Fetched origin' }; + }; + await useRepo.getState().refresh(); + }); + + const fetchButton = page.getByRole('button', { name: 'Fetch', exact: true }); + await fetchButton.click(); + await expect(page.locator('[data-last-fetch]')).toHaveText(/Fetched just now/); + const incomplete = page.locator('[data-fetch-status]'); + await expect(incomplete).toHaveText(/Fetch incomplete/); + await expect(incomplete).toHaveClass(/text-faint/); + await incomplete.hover(); + await expect(page.getByRole('tooltip')).toHaveText('Failed to fetch: upstream'); + + await page.evaluate(async () => { + const { ipc } = await import('/src/core/ipc.ts'); + ipc.fetch = async () => { throw new Error('offline libgit2 detail'); }; + }); + await fetchButton.click(); + await expect(page.locator('[data-last-fetch]')).toBeVisible(); + await incomplete.hover(); + await expect(page.getByRole('tooltip')).toHaveText('Failed to fetch: origin, upstream'); + + await page.evaluate(async () => { + const { useRepo } = await import('/src/features/repository/store.ts'); + useRepo.setState({ lastFetchAt: Date.now() - 2 * 60 * 60_000 }); + }); + await expect(page.locator('[data-last-fetch]')).not.toHaveText(/Fetched just now/); + await page.getByRole('button', { name: 'Pull', exact: true }).click(); + await expect(page.locator('[data-last-fetch]')).toHaveText(/Fetched just now/); + await expect(incomplete).toHaveText(/Fetch incomplete/); + + await page.evaluate(async () => { + const { ipc } = await import('/src/core/ipc.ts'); + ipc.fetch = async () => ({ status: 'ok', message: 'Fetched' }); + }); + await fetchButton.click(); + await expect(incomplete).toHaveCount(0); + + await page.evaluate(async () => { + const [{ ipc }, { useRepo }] = await Promise.all([ + import('/src/core/ipc.ts'), + import('/src/features/repository/store.ts'), + ]); + ipc.remotes = async () => []; + await useRepo.getState().refresh(); + }); + await expect(fetchButton).toBeDisabled(); + await page.mouse.move(0, 0); + await fetchButton.locator('..').hover(); + await expect(page.getByRole('tooltip')).toHaveText('No remotes configured'); +}); + test('the diff header opens blame inside file history with authors per hunk', async ({ page }) => { await page.goto('/'); await page.getByText('angkorgit', { exact: true }).first().click(); diff --git a/tests/unit/fetchRemotes.test.ts b/tests/unit/fetchRemotes.test.ts new file mode 100644 index 00000000..3a299539 --- /dev/null +++ b/tests/unit/fetchRemotes.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { fetchRemotes, fetchResultMessage } from '../../apps/desktop/src/features/repository/fetchRemotes'; + +describe('fetchRemotes', () => { + it('attempts every remote after a failure and returns failures for the caller', async () => { + const attempted: string[] = []; + const result = await fetchRemotes(['origin', 'upstream', 'backup'], async (name) => { + attempted.push(name); + if (name === 'upstream') throw new Error('authentication failed'); + }); + + expect(attempted).toEqual(['origin', 'upstream', 'backup']); + expect(result).toEqual({ + succeeded: ['origin', 'backup'], + failed: [{ name: 'upstream', error: 'authentication failed' }], + }); + expect(fetchResultMessage(result)).toBe('Fetched 2 of 3 remotes. Failed: upstream: authentication failed'); + + const retried: string[] = []; + const retry = await fetchRemotes(['origin', 'upstream', 'backup'], async (name) => { + retried.push(name); + }); + expect(retried).toEqual(['origin', 'upstream', 'backup']); + expect(retry.failed).toEqual([]); + }); +});