From 3878a6d4a688e29e3736ecee8d86ab9556ff5022 Mon Sep 17 00:00:00 2001 From: Liam Russell <17897133+liam-russell@users.noreply.github.com> Date: Fri, 8 May 2026 09:28:27 +1000 Subject: [PATCH 01/11] fix: prioritize default branch, refresh graph on refs changes, and improve hook terminal UX --- src-tauri/src/git/helpers.rs | 5 +- src-tauri/src/git/operations.rs | 26 ++++++ src-tauri/src/watcher.rs | 95 ++++++++++++++++++++- src/lib/components/TerminalContainer.svelte | 6 +- src/lib/sproutgit.ts | 5 ++ src/routes/workspace/+page.svelte | 45 +++++++--- tests/worktree-create-sort.test.ts | 88 +++++++++++++++++++ 7 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 tests/worktree-create-sort.test.ts diff --git a/src-tauri/src/git/helpers.rs b/src-tauri/src/git/helpers.rs index 8a5a658..493b2eb 100644 --- a/src-tauri/src/git/helpers.rs +++ b/src-tauri/src/git/helpers.rs @@ -65,11 +65,12 @@ pub enum GitAction { Pull, ListRemotes, CheckIgnore, + SymbolicRef, } impl GitAction { #[cfg(test)] - pub const ALL: [GitAction; 32] = [ + pub const ALL: [GitAction; 33] = [ GitAction::GitInfo, GitAction::WorktreeList, GitAction::ListRefs, @@ -102,6 +103,7 @@ impl GitAction { GitAction::Pull, GitAction::ListRemotes, GitAction::CheckIgnore, + GitAction::SymbolicRef, ]; pub fn label(self) -> &'static str { @@ -138,6 +140,7 @@ impl GitAction { GitAction::Pull => "pull", GitAction::ListRemotes => "list_remotes", GitAction::CheckIgnore => "check_ignore", + GitAction::SymbolicRef => "symbolic_ref", } } } diff --git a/src-tauri/src/git/operations.rs b/src-tauri/src/git/operations.rs index 3c2ee25..8038b49 100644 --- a/src-tauri/src/git/operations.rs +++ b/src-tauri/src/git/operations.rs @@ -48,6 +48,8 @@ pub struct RefInfo { pub struct RefsResult { pub repo_path: String, pub refs: Vec, + /// Short name of the default remote branch (e.g. `origin/main`), if discoverable. + pub default_remote_branch: Option, } #[derive(Serialize)] @@ -429,6 +431,7 @@ pub async fn list_refs(repo_path: String) -> Result { "tag" }; + // Capture origin/HEAD target before filtering it out if kind == "remote" && short_name.ends_with("/HEAD") { return None; } @@ -442,9 +445,32 @@ pub async fn list_refs(repo_path: String) -> Result { }) .collect(); + // Discover the default remote branch via symbolic-ref (e.g. origin/HEAD -> origin/main). + // Failures are non-fatal; repos without a configured origin/HEAD simply get None. + let default_remote_branch = run_git( + GitAction::SymbolicRef, + &[ + "-C", + &canonical.to_string_lossy(), + "symbolic-ref", + "--short", + "refs/remotes/origin/HEAD", + ], + ) + .ok() + .and_then(|o| { + if o.status.success() { + let s = String::from_utf8_lossy(&o.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } + } else { + None + } + }); + Ok(RefsResult { repo_path: path_to_frontend(&canonical), refs, + default_remote_branch, }) } diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs index ae0e767..d505e4d 100644 --- a/src-tauri/src/watcher.rs +++ b/src-tauri/src/watcher.rs @@ -64,6 +64,35 @@ fn match_git_index_to_worktree( } } +/// Returns `true` if the path inside `.git/` signals that branches or HEAD state changed. +/// +/// Matches (relative to `git_dir`): +/// `HEAD` → checkout in the main worktree +/// `COMMIT_EDITMSG` → any commit was just created +/// `packed-refs` → refs were repacked (push/fetch/gc) +/// `refs/**` → any ref update (branch advance, new tag, etc.) +/// `worktrees//HEAD` → checkout in a linked worktree +fn is_git_refs_change(event_path: &Path, git_dir: &Path) -> bool { + let rel = match event_path.strip_prefix(git_dir) { + Ok(r) => r, + Err(_) => return false, + }; + let comps: Vec<_> = rel.components().collect(); + match comps.as_slice() { + [c] if c.as_os_str() == OsStr::new("HEAD") => true, + [c] if c.as_os_str() == OsStr::new("COMMIT_EDITMSG") => true, + [c] if c.as_os_str() == OsStr::new("packed-refs") => true, + [c, ..] if c.as_os_str() == OsStr::new("refs") => true, + [a, _, c] + if a.as_os_str() == OsStr::new("worktrees") + && c.as_os_str() == OsStr::new("HEAD") => + { + true + }, + _ => false, + } +} + /// Returns `true` if `path` matches a gitignore rule inside `worktree_path`. /// Uses `git check-ignore -q -- ` (exit 0 = ignored, 1 = not ignored). /// Falls back to `false` (treat as real change) on any error so that a git @@ -155,10 +184,11 @@ pub async fn start_watching_worktrees( }; let mut affected: HashSet = HashSet::new(); + let mut refs_changed = false; for event in &events { let event_path = &event.path; - // ── Git index changes (external staging) ── + // ── Git index / refs changes ── if let Some(ref git_dir) = git_dir_for_closure { let git_dir_path = Path::new(git_dir); if event_path.starts_with(git_dir_path) { @@ -173,6 +203,10 @@ pub async fn start_watching_worktrees( if Path::new(&wt).is_dir() { affected.insert(wt); } + } else if is_git_refs_change(event_path, git_dir_path) { + // A branch ref, HEAD, or commit marker changed — the graph + // and worktree list may be stale (e.g. terminal commit/checkout). + refs_changed = true; } // Skip further processing for all .git dir events regardless. continue; @@ -200,6 +234,9 @@ pub async fn start_watching_worktrees( for wt_path in affected { let _ = app.emit("worktree-changed", &wt_path); } + if refs_changed { + let _ = app.emit("git-refs-changed", ()); + } }, ) .map_err(|e| format!("Failed to create file watcher: {e}"))?; @@ -240,3 +277,59 @@ pub async fn stop_watching_worktrees(state: State<'_, WatcherState>) -> Result<( *guard = None; Ok(()) } + +#[cfg(test)] +mod tests { + use super::is_git_refs_change; + use std::path::Path; + + fn git(extra: &str) -> std::path::PathBuf { + Path::new("/repo/.git").join(extra) + } + const GIT_DIR: &str = "/repo/.git"; + + #[test] + fn matches_head() { + assert!(is_git_refs_change(&git("HEAD"), Path::new(GIT_DIR))); + } + + #[test] + fn matches_commit_editmsg() { + assert!(is_git_refs_change(&git("COMMIT_EDITMSG"), Path::new(GIT_DIR))); + } + + #[test] + fn matches_packed_refs() { + assert!(is_git_refs_change(&git("packed-refs"), Path::new(GIT_DIR))); + } + + #[test] + fn matches_refs_prefix() { + assert!(is_git_refs_change(&git("refs/heads/main"), Path::new(GIT_DIR))); + assert!(is_git_refs_change(&git("refs/remotes/origin/main"), Path::new(GIT_DIR))); + assert!(is_git_refs_change(&git("refs/tags/v1.0"), Path::new(GIT_DIR))); + } + + #[test] + fn matches_worktree_head() { + assert!(is_git_refs_change( + &git("worktrees/feature-foo/HEAD"), + Path::new(GIT_DIR) + )); + } + + #[test] + fn does_not_match_index() { + assert!(!is_git_refs_change(&git("index"), Path::new(GIT_DIR))); + assert!(!is_git_refs_change(&git("ORIG_HEAD"), Path::new(GIT_DIR))); + assert!(!is_git_refs_change(&git("worktrees/feature-foo/index"), Path::new(GIT_DIR))); + } + + #[test] + fn does_not_match_outside_git_dir() { + assert!(!is_git_refs_change( + Path::new("/repo/some-file.rs"), + Path::new(GIT_DIR) + )); + } +} diff --git a/src/lib/components/TerminalContainer.svelte b/src/lib/components/TerminalContainer.svelte index b102045..1fb9e93 100644 --- a/src/lib/components/TerminalContainer.svelte +++ b/src/lib/components/TerminalContainer.svelte @@ -56,7 +56,11 @@ $effect(() => { if (!_autoSpawned && defaultShell) { _autoSpawned = true; - addSession(defaultShell); + // If a hook launch request is already queued at mount time, skip the blank + // default session — the launchRequest effect below will add the correct one. + if (!launchRequest) { + addSession(defaultShell); + } } }); diff --git a/src/lib/sproutgit.ts b/src/lib/sproutgit.ts index 0d27b37..46d295f 100644 --- a/src/lib/sproutgit.ts +++ b/src/lib/sproutgit.ts @@ -55,6 +55,8 @@ export type RefInfo = { export type RefsResult = { repoPath: string; refs: RefInfo[]; + /** Short name of the default remote branch (e.g. `origin/main`), if discoverable. */ + defaultRemoteBranch?: string; }; export type CommitEntry = { @@ -582,6 +584,9 @@ export const stopWatchingWorktrees = () => invoke('stop_watching_worktrees export const onWorktreeChanged = (callback: (worktreePath: string) => void): Promise => listen('worktree-changed', event => callback(event.payload)); +export const onGitRefsChanged = (callback: () => void): Promise => + listen('git-refs-changed', () => callback()); + // ── Terminal ── export const listAvailableShells = () => invoke('list_available_shells'); diff --git a/src/routes/workspace/+page.svelte b/src/routes/workspace/+page.svelte index 7b22e9e..1c6af5b 100644 --- a/src/routes/workspace/+page.svelte +++ b/src/routes/workspace/+page.svelte @@ -42,6 +42,7 @@ startWatchingWorktrees, stopWatchingWorktrees, onWorktreeChanged, + onGitRefsChanged, getAppSetting, listAvailableShells, type StatusFileEntry, @@ -86,6 +87,7 @@ let worktrees = $state([]); let graph = $state(null); let refs = $state([]); + let defaultRemoteBranch = $state(undefined); let selectedRef = $state(''); let newBranch = $state(''); let activeWorktreePath = $state(null); @@ -707,11 +709,13 @@ function compareRefsForCreate(a: RefInfo, b: RefInfo): number { const rank = (ref: RefInfo): number => { - if (ref.kind === 'remote' && ref.name.startsWith('upstream/')) return 0; - if (ref.kind === 'remote' && ref.name.startsWith('origin/')) return 1; - if (ref.kind === 'remote') return 2; - if (ref.kind === 'branch') return 3; - return 4; + // The detected default branch (e.g. origin/main) gets highest priority. + if (defaultRemoteBranch && ref.name === defaultRemoteBranch) return 0; + if (ref.kind === 'remote' && ref.name.startsWith('upstream/')) return 1; + if (ref.kind === 'remote' && ref.name.startsWith('origin/')) return 2; + if (ref.kind === 'remote') return 3; + if (ref.kind === 'branch') return 4; + return 5; }; const rankDiff = rank(a) - rank(b); @@ -720,6 +724,11 @@ } function preferredSourceRef(refList: RefInfo[]): string { + // If the default remote branch is known and present in the ref list, use it directly. + if (defaultRemoteBranch) { + const defaultRef = refList.find(r => r.name === defaultRemoteBranch); + if (defaultRef) return defaultRef.name; + } const sorted = [...refList].sort(compareRefsForCreate); const preferred = sorted.find(ref => ref.kind === 'remote') ?? sorted[0]; return preferred?.name ?? 'HEAD'; @@ -1188,6 +1197,7 @@ worktrees = worktreeData.worktrees; refs = refsData.refs; + defaultRemoteBranch = refsData.defaultRemoteBranch; initializeGraphState(graphData); selectedRef = preferredSourceRef(refsData.refs); @@ -1249,6 +1259,9 @@ creating = true; error = ''; + // Close the modal immediately so the operation status banner (hook progress) + // is visible behind the now-dismissed dialog while hooks are running. + createModalOpen = false; try { await beginOperation( @@ -1324,9 +1337,17 @@ const unlistenHookProgress = onHookProgress(handleHookProgress); const unlistenHookTerminalLaunch = onHookTerminalLaunch(handleHookTerminalLaunch); + // When git refs change externally (e.g. terminal commits/checkouts), refresh + // the graph and worktrees list so the History tab stays up to date. + const unlistenGitRefsChanged = onGitRefsChanged(() => { + if (!committing && !stagingAction) { + void refreshWorkspaceData(); + } + }); onDestroy(() => { void unlistenHookProgress.then(unlisten => unlisten()); void unlistenHookTerminalLaunch.then(unlisten => unlisten()); + void unlistenGitRefsChanged.then(unlisten => unlisten()); }); function handleHookTerminalLaunch(event: HookTerminalLaunchEvent) { @@ -1657,6 +1678,7 @@ worktrees = refreshedWt.worktrees; initializeGraphState(refreshedGraph); refs = refreshedRefs.refs; + defaultRemoteBranch = refreshedRefs.defaultRemoteBranch; if (graphHasMore) { // Refresh total commit count in the background only when the graph is partial. totalCommitCount = null; @@ -3001,9 +3023,11 @@ cwd={wtPath} launchRequest={hookTerminalLaunchRequest} forcedLayout={shouldLockHookTerminals ? 'grid' : null} - interactionLocked={Boolean(runningHooksByCwd[wtPath])} - lockReason={activeHookName - ? `Running ${activeHookName} (input locked)` + interactionLocked={Boolean(operationStatus) || Boolean(runningHooksByCwd[wtPath])} + lockReason={operationStatus + ? activeHookName + ? `Running ${activeHookName} (input locked)` + : 'Operation in progress (input locked)' : 'Hook run in progress (input locked)'} /> @@ -3153,10 +3177,7 @@
{ - await createFirstWorktree(e); - if (!error) createModalOpen = false; - }} + onsubmit={createFirstWorktree} class="px-4 py-3" >

{ + if (defaultRemoteBranch && ref.name === defaultRemoteBranch) return 0; + if (ref.kind === 'remote' && ref.name.startsWith('upstream/')) return 1; + if (ref.kind === 'remote' && ref.name.startsWith('origin/')) return 2; + if (ref.kind === 'remote') return 3; + if (ref.kind === 'branch') return 4; + return 5; + }; + const rankDiff = rank(a) - rank(b); + if (rankDiff !== 0) return rankDiff; + return a.name.localeCompare(b.name); + }; +} + +function preferredSourceRef(refList: RefInfo[], defaultRemoteBranch?: string): string { + if (defaultRemoteBranch) { + const defaultRef = refList.find(r => r.name === defaultRemoteBranch); + if (defaultRef) return defaultRef.name; + } + const sorted = [...refList].sort(makeCompare(defaultRemoteBranch)); + const preferred = sorted.find(ref => ref.kind === 'remote') ?? sorted[0]; + return preferred?.name ?? 'HEAD'; +} + +function ref(name: string, kind: RefInfo['kind'] = 'remote'): RefInfo { + return { name, fullName: name, kind, target: '' }; +} + +describe('compareRefsForCreate', () => { + it('ranks default remote branch first when specified', () => { + const refs = [ref('origin/alpha'), ref('origin/main'), ref('origin/zebra')]; + const sorted = [...refs].sort(makeCompare('origin/main')); + expect(sorted[0].name).toBe('origin/main'); + }); + + it('ranks upstream/* before origin/* when no default is set', () => { + const refs = [ref('origin/main'), ref('upstream/main')]; + const sorted = [...refs].sort(makeCompare()); + expect(sorted[0].name).toBe('upstream/main'); + }); + + it('ranks origin/* before other remotes', () => { + const refs = [ref('fork/feature'), ref('origin/main')]; + const sorted = [...refs].sort(makeCompare()); + expect(sorted[0].name).toBe('origin/main'); + }); + + it('ranks remote branches before local branches', () => { + const refs = [ref('feature', 'branch'), ref('origin/main')]; + const sorted = [...refs].sort(makeCompare()); + expect(sorted[0].kind).toBe('remote'); + }); + + it('sorts alphabetically within the same rank', () => { + const refs = [ref('origin/zebra'), ref('origin/alpha'), ref('origin/main')]; + const sorted = [...refs].sort(makeCompare()); + expect(sorted.map(r => r.name)).toEqual(['origin/alpha', 'origin/main', 'origin/zebra']); + }); +}); + +describe('preferredSourceRef', () => { + it('returns defaultRemoteBranch when it is in the list', () => { + const refs = [ref('origin/alpha'), ref('origin/main'), ref('origin/zebra')]; + expect(preferredSourceRef(refs, 'origin/main')).toBe('origin/main'); + }); + + it('falls back to highest-ranked remote when default is not in list', () => { + const refs = [ref('origin/alpha'), ref('origin/zebra')]; + expect(preferredSourceRef(refs, 'origin/main')).toBe('origin/alpha'); + }); + + it('falls back to upstream/* over origin/* when no default', () => { + const refs = [ref('origin/main'), ref('upstream/main')]; + expect(preferredSourceRef(refs)).toBe('upstream/main'); + }); + + it('returns HEAD when ref list is empty', () => { + expect(preferredSourceRef([])).toBe('HEAD'); + }); +}); From 46e355b9c29fcd2f9ffe45bed30cbb82039715a8 Mon Sep 17 00:00:00 2001 From: Liam Russell <17897133+liam-russell@users.noreply.github.com> Date: Fri, 8 May 2026 10:19:00 +1000 Subject: [PATCH 02/11] test: align hook terminal session count expectation --- e2e/specs/daily-workflow.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/specs/daily-workflow.spec.ts b/e2e/specs/daily-workflow.spec.ts index 7096b81..0f0ebbc 100644 --- a/e2e/specs/daily-workflow.spec.ts +++ b/e2e/specs/daily-workflow.spec.ts @@ -707,7 +707,7 @@ test.describe('Daily developer workflow', () => { const terminalPanels = tauriPage.locator('[data-sg-terminal] [data-pty-id]'); const panelCount = await terminalPanels.count(); - expect(panelCount).toBeGreaterThanOrEqual(3); + expect(panelCount).toBeGreaterThanOrEqual(2); const hookRows = querySqlite( stateDbPath, From 26aeef7c47e78a2a35c6391220bdba14ff7bbdae Mon Sep 17 00:00:00 2001 From: Liam Russell <17897133+liam-russell@users.noreply.github.com> Date: Fri, 8 May 2026 10:36:57 +1000 Subject: [PATCH 03/11] fix: stabilize root and terminal workspace tabs --- e2e/specs/daily-workflow.spec.ts | 7 +++- src/routes/workspace/+page.svelte | 66 ++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/e2e/specs/daily-workflow.spec.ts b/e2e/specs/daily-workflow.spec.ts index 0f0ebbc..10e332c 100644 --- a/e2e/specs/daily-workflow.spec.ts +++ b/e2e/specs/daily-workflow.spec.ts @@ -619,10 +619,15 @@ test.describe('Daily developer workflow', () => { await changesTab.waitFor(DEFAULT_UI_TIMEOUT); await terminalTab.waitFor(DEFAULT_UI_TIMEOUT); - expect(await historyTab.getAttribute('disabled')).not.toBeNull(); + expect(await historyTab.getAttribute('disabled')).toBeNull(); expect(await changesTab.getAttribute('disabled')).not.toBeNull(); expect(await terminalTab.getAttribute('disabled')).toBeNull(); + await historyTab.click(); + await tauriPage.evaluate(`(() => { + return (document.body?.textContent ?? '').includes('Commit graph'); + })()`); + await terminalTab.click(); await tauriPage.getByTestId('btn-add-terminal').waitFor(DEFAULT_UI_TIMEOUT); diff --git a/src/routes/workspace/+page.svelte b/src/routes/workspace/+page.svelte index 1c6af5b..f6a14b2 100644 --- a/src/routes/workspace/+page.svelte +++ b/src/routes/workspace/+page.svelte @@ -706,6 +706,9 @@ const selectedWorktree = $derived( worktrees.find(item => pathsEqual(item.path, activeWorktreePath)) ?? null ); + const hasManagedWorktreeSelection = $derived( + Boolean(selectedWorktree && !pathsEqual(selectedWorktree.path, workspace?.rootPath)) + ); function compareRefsForCreate(a: RefInfo, b: RefInfo): number { const rank = (ref: RefInfo): number => { @@ -808,7 +811,7 @@ } }); - const canUseWorktreeViews = $derived(Boolean(selectedWorktree && !activeIsRoot)); + const canUseChangesView = $derived(hasManagedWorktreeSelection); // Per-worktree change counts for sidebar badges let worktreeChangeCounts = $state>({}); @@ -896,8 +899,10 @@ } async function loadWorktreeStatus() { - if (!selectedWorktree) { + if (!selectedWorktree || !hasManagedWorktreeSelection) { worktreeStatus = []; + stagingDiffFile = null; + stagingDiffContent = ''; return; } statusLoading = true; @@ -942,6 +947,19 @@ } async function refreshWorktreeStatusFromWatcher(changedPath: string) { + if (deleting && pathsEqual(changedPath, deleting)) { + return; + } + + if (workspace && pathsEqual(changedPath, workspace.rootPath)) { + if (!hasManagedWorktreeSelection) { + worktreeStatus = []; + stagingDiffFile = null; + stagingDiffContent = ''; + } + return; + } + const result = await getWorktreeStatus(changedPath); worktreeChangeCounts[changedPath] = result.files.length; if (pathsEqual(changedPath, selectedWorktree?.path)) { @@ -1108,6 +1126,12 @@ } }); + $effect(() => { + if (activeTab === 'changes' && !canUseChangesView) { + activeTab = 'history'; + } + }); + // ── File Watcher ── let unlistenWorktreeChanged: (() => void) | null = null; @@ -1117,6 +1141,9 @@ // listWorktrees returns forward-slash paths. Normalise separators only — // never lowercase, since Linux filesystems are case-sensitive. const changedPath = normalizePathSeparators(changedPathRaw); + if (deleting && pathsEqual(changedPath, deleting)) { + return; + } if (stagingAction || committing) { queueWatcherRefresh(changedPath); return; @@ -1541,6 +1568,24 @@ onconfirm: async () => { confirmDialog = null; deleting = wt.path; + const nextWorktreePath = + worktrees.find( + worktree => + !pathsEqual(worktree.path, wt.path) && !pathsEqual(worktree.path, workspace!.rootPath) + )?.path ?? + worktrees.find(worktree => !pathsEqual(worktree.path, wt.path))?.path ?? + null; + const { [wt.path]: _removedChangeCount, ...remainingChangeCounts } = worktreeChangeCounts; + worktreeChangeCounts = remainingChangeCounts; + + if (activeWorktreePath && pathsEqual(activeWorktreePath, wt.path)) { + activeWorktreePath = nextWorktreePath; + activeTerminalPath = nextWorktreePath ?? workspace!.workspacePath; + worktreeStatus = []; + stagingDiffFile = null; + stagingDiffContent = ''; + } + try { await beginOperation( 'Removing worktree', @@ -1554,13 +1599,6 @@ } catch { worktrees = worktrees.filter(worktree => worktree.path !== wt.path); } - if (activeWorktreePath === wt.path) { - activeWorktreePath = - worktrees.find(w => !pathsEqual(w.path, workspace!.rootPath))?.path ?? - worktrees[0]?.path ?? - null; - activeTerminalPath = activeWorktreePath ?? workspace!.workspacePath; - } } catch (err) { failOperation(String(err)); toast.error(String(err)); @@ -2476,10 +2514,8 @@ >