Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ 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)
- **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

Expand Down
17 changes: 12 additions & 5 deletions CLAUDE.md

Large diffs are not rendered by default.

37 changes: 28 additions & 9 deletions apps/desktop/src-tauri/src/core/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
fn local_tag_names(repo: &Repository) -> AppResult<Vec<String>> {
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<String> {
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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -916,29 +935,29 @@ 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(),
]
);
}

#[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(),
]
);
}

#[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()]
);
}
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src-tauri/tests/git_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -142,7 +143,7 @@ export function CommandPalette({ onRefresh }: { onRefresh: () => Promise<void> }
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) {
Expand Down Expand Up @@ -335,7 +336,13 @@ export function CommandPalette({ onRefresh }: { onRefresh: () => Promise<void> }
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 };
})
}
/>
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/components/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -115,6 +116,14 @@ export function StatusBar() {
</span>
</Hint>
)}
{fetchFailures.length > 0 && (
<Hint label={`Failed to fetch: ${fetchFailures.join(', ')}`}>
<span className="flex items-center gap-1 text-faint" data-fetch-status>
<RefreshCw className="size-3" />
Fetch incomplete
</span>
</Hint>
)}
<span className={cn('flex items-center gap-1.5', changes > 0 && 'text-primary')}>
{changes > 0 ? <Pencil className="size-3" /> : <Check className="size-3 text-success" />}
{changes > 0 ? `${changes} change${changes === 1 ? '' : 's'}` : 'Clean'}
Expand Down
39 changes: 24 additions & 15 deletions apps/desktop/src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -388,7 +389,7 @@ export function Toolbar({ onRefresh }: { onRefresh: () => Promise<void> }) {
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 {
Expand Down Expand Up @@ -442,20 +443,28 @@ export function Toolbar({ onRefresh }: { onRefresh: () => Promise<void> }) {

<Separator orientation="vertical" className="mx-2 h-6" />

<Hint label={remotes.length > 1 ? `Fetch all remotes (${remotes.map((r) => r.name).join(', ')})` : `Fetch ${remote}`}>
<Button
variant="ghost"
size="sm"
disabled={!!busy}
onClick={() =>
void run('Fetch', async () => {
for (const r of remotes.length > 0 ? remotes : [{ name: remote }]) await ipc.fetch(repo.path, r.name, true, true);
})
}
>
<RefreshCw className={busy === 'Fetch' ? 'animate-spin' : ''} />
Fetch
</Button>
<Hint label={remotes.length === 0 ? 'No remotes configured' : remotes.length > 1 ? `Fetch all remotes (${remotes.map((r) => r.name).join(', ')})` : `Fetch ${remote}`}>
<span className="inline-flex">
<Button
variant="ghost"
size="sm"
disabled={!!busy || remotes.length === 0}
onClick={() =>
void run('Fetch', async () => {
const result = await fetchRemotes(remotes.map((r) => r.name), (name) => ipc.fetch(repo.path, name, true, true));
const message = fetchResultMessage(result);
if (useRepo.getState().repo?.path === repo.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 };
})
}
>
<RefreshCw className={busy === 'Fetch' ? 'animate-spin' : ''} />
Fetch
</Button>
</span>
</Hint>
<div className="flex items-center">
<Hint label={`Pull from ${remote}${status?.behind ? ` (${status.behind} behind)` : ''} · merge or rebase per pull.rebase`}>
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/features/inspector/Inspector.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -129,7 +129,7 @@ export function Inspector() {
className={cn(fileView === 'all' && 'bg-surface-raised text-foreground')}
onClick={() => setFileView('all')}
>
<Files className="size-3.5" />
<FolderOpen className="size-3.5" />
</Button>
</Hint>
{(commit || commitError) && (
Expand Down
20 changes: 12 additions & 8 deletions apps/desktop/src/features/repository/RepositoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand All @@ -220,6 +223,7 @@ export function RepositoryPage() {
window.addEventListener('focus', onFocus);
void tick();
return () => {
cancelled = true;
window.clearInterval(id);
window.removeEventListener('focus', onFocus);
};
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/features/repository/fetchRemotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export interface FetchResult {
succeeded: string[];
failed: { name: string; error: string }[];
}

export async function fetchRemotes(
names: string[],
fetchOne: (name: string) => Promise<unknown>,
): Promise<FetchResult> {
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}`;
}
8 changes: 7 additions & 1 deletion apps/desktop/src/features/repository/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface RepoState {
refreshing: boolean;
profileId: string | null;
lastFetchAt: number | null;
fetchFailures: string[];

loadRecents: () => Promise<void>;
open: (path: string) => Promise<RepositoryInfo>;
Expand All @@ -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;
Expand All @@ -63,6 +65,7 @@ export const useRepo = create<RepoState>((set, get) => ({
refreshing: false,
profileId: null,
lastFetchAt: null,
fetchFailures: [],

loadRecents: async () => {
const recents = await ipc.recentRepositories();
Expand All @@ -86,6 +89,7 @@ export const useRepo = create<RepoState>((set, get) => ({
repo,
profileId: null,
lastFetchAt: null,
fetchFailures: [],
opening: null,
refreshing: true,
status: null,
Expand All @@ -99,7 +103,7 @@ export const useRepo = create<RepoState>((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')
Expand Down Expand Up @@ -136,6 +140,7 @@ export const useRepo = create<RepoState>((set, get) => ({
refreshing: false,
profileId: null,
lastFetchAt: null,
fetchFailures: [],
}),

refresh: async () => {
Expand Down Expand Up @@ -189,5 +194,6 @@ export const useRepo = create<RepoState>((set, get) => ({

setBusy: (busy) => set({ busy }),
markFetched: () => set({ lastFetchAt: Date.now() }),
setFetchFailures: (fetchFailures) => set({ fetchFailures }),
setProfileId: (profileId) => set({ profileId }),
}));
3 changes: 2 additions & 1 deletion apps/desktop/src/shared/toastOutcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading
Loading