diff --git a/CLAUDE.md b/CLAUDE.md index 2d3d6e5..dedd738 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,7 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js) | `ColorService` | FNV-1a hash of folder path → 12-color palette | | `GitService` | Async `git branch --show-current` + `git status --porcelain`. **Every await is `ConfigureAwait(false)`, `RunGitFullAsync` is `Task.Run`-wrapped, and command lines are built as argv via `JoinArgv` — do not "simplify" any of the three away.** WSL repos are dispatched through `wsl.exe -d -e git …`; `-e` is a security boundary, see "Never interpolate a value into a command line" | | `GitRepoWatcher` | Shared, reference-counted `FileSystemWatcher` on a repo's `.git/HEAD` + `index`, debounced 400ms. Lets git state refresh on checkout/commit/stage instead of by polling. Resolves the `gitdir:` indirection so a linked worktree watches its own HEAD, not the main repo's. Acquire/Release per session — one watcher per `.git` dir, however many sessions share it. Returns null outside a repo — callers treat that as "poll only", not an error | +| `GitStatusText` | WPF-free wording for the toolbar git chip and menu: ↑/↓ counts, the menu header line, the post-fetch toast, and a short hint recognised from git's own failure text. See "Git tools on the terminal toolbar" | | `WslDiscoveryService` | `wsl.exe` probes for distro list, `$HOME` and login shell, each cached per (distro, user) and capped at 3s. All three go through `RunWslCaptureAsync`, which is `Task.Run`-wrapped — **never call `Process.Start` inline here**, `wsl.exe` can boot a stopped distro VM | | `ShellIntegrationPayload` | WPF-free validation for OSC 9001. Every field is untrusted: colour is strict hex, dirty is an allowlist, title and branch strip control characters and are length-capped (`MaxTitleLength` 80, `MaxBranchLength` 200) | | `AlertDetector` | Pattern matching for Claude prompts/approvals | @@ -731,6 +732,69 @@ Each session can have a list of "run commands" — labelled command lines invoke **Lifecycle:** All runs are killed on session close, session sleep, and app exit. `SessionViewModel.Dispose()` calls `Runner.Dispose()` which iterates and disposes every instance. `SleepSession` also calls `vm.Runner.StopAll()` defensively before UI teardown. +## Git tools on the terminal toolbar + +When a session's folder is a git repo, its terminal toolbar shows a chip — `⎇ main ● ↑2 ↓5` +(branch, peach dirty dot, blue ahead/behind) — just left of ▶. Clicking it opens a menu: +a header line (`main → origin/main · ↑2 ↓5 · 3 changed`), **Fetch**, **Pull (fast-forward +only)**, **Status…**, **Switch branch ▸** (local branches, loaded when the submenu opens), +**Copy branch name**, and **Open in VS Code** when `code.cmd` is on PATH. The UI is in +`MainWindow.Git.cs`; git calls are in `GitService`. Hidden for SSH sessions — an OSC 9001 +branch is display-only, there is no local folder to run git in. + +**Deliberately read-mostly: no commit, push or merge.** A Claude session is usually editing +that same working tree, and a one-click history write from outside its conversation is both a +race and a misclick nobody can take back. Pull is `--ff-only --no-rebase`: it moves the branch +only when that loses nothing, and on a diverged branch or a conflicting local edit git refuses +and the panel shows why — it never stashes or merges on the user's behalf. Push, if ever +added, should confirm and show exactly what is going out. + +**Nothing is typed into the terminal.** Every operation is its own background git process via +`GitService` — in a Claude session, typed text would land in Claude's prompt. + +**One status call now feeds everything.** The poll used `branch --show-current` + +`status --porcelain` (two spawns). It is now one `status --porcelain=v2 --branch` +(`GitService.GetStatusAsync` / `ParseStatusV2`), which also yields upstream, ahead/behind and +the changed-file list — cheaper *and* richer, since a spawn is ~all of the cost (see "Never +spawn a process on the UI thread"). `GetGitInfoAsync` keeps its old contract on top of it, +detached HEAD → `(null, false)` included. The extra fields (`GitUpstream`, `GitAhead`, +`GitBehind`, `GitChangeCount`) follow the same rule as `GitBranch`: plain properties, +announced only through `GitInfoVersion`. An upstream that is configured but whose remote +branch is gone produces no `# branch.ab` line, so it reads "upstream gone", not "up to date". + +**Environment for every git spawn** (`GitService.GitEnvironment`): +- `GIT_TERMINAL_PROMPT=0` — git has no console, so an HTTPS credential prompt would wait for + input that can never come until the timeout. With it, git fails at once ("terminal prompts + disabled") and `GitStatusText.FailureHint` says to sign in from a terminal once. GUI + credential helpers (Git Credential Manager) are unaffected. +- `GIT_OPTIONAL_LOCKS=0` on read-only calls — a plain `git status` opportunistically takes + `index.lock`, which is how a background poll makes a git command the user (or Claude) runs + at the same moment fail. +- WSL: Windows env vars reach a distro only when `WSLENV` names them, so both are merged into + it (`MergeWslEnv`) — without that, the git inside WSL never saw them. + +A timed-out run now kills the **whole process tree** — a fetch's real work happens in +children (`git-remote-https`, `ssh`) that killing `git.exe` alone left running. + +**Switch branch** runs `switch --no-guess -- `. The name comes from the repo and a ref +can legally be called `-x`; `--` keeps it a branch name (verified against git 2.54 with a +branch literally named `-x`). `--no-guess` stops git silently creating a tracking branch for a +name that exists only on a remote. + +**One operation per repo at a time** (`_gitBusyRepos`, keyed by `RepoRoot` so worktrees of one +repo share the slot); the chip shows `⟳` meanwhile. Afterwards *every* live pane on that repo +re-reads git state explicitly: a fetch moves only remote refs, which `GitRepoWatcher` (HEAD + +index) never sees. Success → a toast; failure → the git panel (docked under the toolbar, like +notes) opens with git's own output plus the recognised hint. + +Covered by `GitToolsTests.cs`: the parser against captured `porcelain=v2` output, the wording, +the argv (hostile and option-shaped branch names round-tripped through `CommandLineToArgvW`), +and the operations end to end against a throwaway bare "remote" — fast-forward, refusing a +diverged branch even with `pull.rebase=true`, refusing to overwrite a local edit, the `-x` +branch, and the remote-only branch `--no-guess` must not invent. Removing `--` or `--no-guess` +fails 6 tests each; removing `--no-rebase` fails none, because git 2.54 already lets +`--ff-only` win — it is kept as a guard for other git versions. + ## Per-Session Notes Each session gets a collapsible 📝 notepad panel between the terminal toolbar and the terminal. Toggled by the 📝 button on the terminal toolbar; the panel is a docked 160px-high `TextBox` (`Visibility.Collapsed` by default). diff --git a/src/CodeShellManager/MainWindow.Git.cs b/src/CodeShellManager/MainWindow.Git.cs new file mode 100644 index 0000000..dcf73f7 --- /dev/null +++ b/src/CodeShellManager/MainWindow.Git.cs @@ -0,0 +1,414 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using CodeShellManager.Models; +using CodeShellManager.Services; +using CodeShellManager.ViewModels; +using Brushes = System.Windows.Media.Brushes; +using Color = System.Windows.Media.Color; +using FontFamily = System.Windows.Media.FontFamily; +using WpfButton = System.Windows.Controls.Button; +using WpfTextBox = System.Windows.Controls.TextBox; +using Clipboard = System.Windows.Clipboard; +using ContextMenu = System.Windows.Controls.ContextMenu; +using MenuItem = System.Windows.Controls.MenuItem; + +namespace CodeShellManager; + +/// +/// The terminal toolbar's git chip ("⎇ main ● ↑2 ↓5") and its menu: fetch, fast-forward pull, +/// status, switch branch. See "Git tools on the terminal toolbar" in CLAUDE.md. +/// +/// Every operation runs as its own background git process via — +/// nothing is ever typed into the session's terminal, which in a Claude session would land +/// as text in Claude's prompt. +/// +public partial class MainWindow +{ + /// Per-session git controls, so a repo-wide change can repaint every pane on it. + private sealed class GitPaneUi + { + public required WpfButton Chip; + public required TextBlock ChipText; + public required Border Panel; + public required TextBlock PanelHeader; + public required WpfTextBox PanelText; + } + + private readonly Dictionary _gitPanes = []; + + /// + /// Repos with a fetch / pull / switch in flight. One at a time per repo: two pulls racing + /// on one .git directory just makes the second fail on index.lock, and a fetch triggered + /// from a second pane of the same repo would be pure duplication. Keyed by RepoRoot, so + /// worktrees of one repository (which share refs) share the slot. + /// + private readonly HashSet _gitBusyRepos = new(StringComparer.OrdinalIgnoreCase); + + private static Task? _vsCodeLookup; + private const int MaxChipBranchChars = 32; + + private static readonly SolidColorBrush GitMuted = Frozen(0xa6, 0xad, 0xc8); + private static readonly SolidColorBrush GitDirty = Frozen(0xfa, 0xb3, 0x87); // peach + private static readonly SolidColorBrush GitSync = Frozen(0x89, 0xb4, 0xfa); // blue + private static readonly SolidColorBrush GitText = Frozen(0xcd, 0xd6, 0xf4); + + private static SolidColorBrush Frozen(byte r, byte g, byte b) + { + var br = new SolidColorBrush(Color.FromRgb(r, g, b)); + br.Freeze(); + return br; + } + + private static string GitRepoKey(SessionViewModel vm) => + string.IsNullOrEmpty(vm.RepoRoot) ? vm.WorkingFolder : vm.RepoRoot; + + /// + /// Builds the chip (docked into the terminal toolbar) and the output panel (docked under + /// it, like the notes panel). Both start hidden; the chip appears once the session is + /// known to be in a repo. + /// + private (WpfButton chip, Border panel) BuildGitControls(SessionViewModel vm) + { + _vsCodeLookup ??= Task.Run(FindVsCode); + + var chipText = new TextBlock { VerticalAlignment = VerticalAlignment.Center, FontSize = 11 }; + var chip = new WpfButton + { + Content = chipText, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Cursor = System.Windows.Input.Cursors.Hand, + Padding = new Thickness(6, 2, 6, 2), + Margin = new Thickness(0, 0, 6, 0), + Visibility = Visibility.Collapsed, + ToolTip = "Git: fetch, pull, status, switch branch", + }; + chip.Click += (_, _) => ShowGitMenu(vm, chip); + + // ── output panel ──────────────────────────────────────────────────────────── + var header = new TextBlock + { + Foreground = GitText, + FontSize = 11, + FontWeight = FontWeights.SemiBold, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + }; + var refreshBtn = MakeDrawerActionButton("↻ Status"); + var copyBtn = MakeDrawerActionButton("📋 Copy"); + var closeBtn = MakeDrawerActionButton("✕"); + var actions = new DockPanel { LastChildFill = true }; + DockPanel.SetDock(closeBtn, Dock.Right); + DockPanel.SetDock(copyBtn, Dock.Right); + DockPanel.SetDock(refreshBtn, Dock.Right); + actions.Children.Add(closeBtn); + actions.Children.Add(copyBtn); + actions.Children.Add(refreshBtn); + actions.Children.Add(header); + + var text = new WpfTextBox + { + Background = new SolidColorBrush(Color.FromRgb(0x18, 0x18, 0x25)), + Foreground = GitText, + BorderThickness = new Thickness(0), + IsReadOnly = true, + AcceptsReturn = true, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + FontFamily = new FontFamily("Consolas"), + FontSize = 12, + Padding = new Thickness(8, 6, 8, 6), + TextWrapping = TextWrapping.NoWrap, + }; + var inner = new DockPanel(); + DockPanel.SetDock(actions, Dock.Top); + inner.Children.Add(actions); + inner.Children.Add(text); + var panel = new Border + { + Background = new SolidColorBrush(Color.FromRgb(0x11, 0x11, 0x1b)), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x31, 0x32, 0x44)), + BorderThickness = new Thickness(0, 0, 0, 1), + Padding = new Thickness(8, 2, 0, 0), + Height = 180, + Visibility = Visibility.Collapsed, + Child = inner, + }; + + refreshBtn.Click += async (_, _) => await ShowGitStatusAsync(vm); + copyBtn.Click += (_, _) => + { + string t = string.IsNullOrEmpty(text.SelectedText) ? text.Text : text.SelectedText; + if (!string.IsNullOrEmpty(t)) { try { Clipboard.SetText(t); } catch { } } + }; + closeBtn.Click += (_, _) => panel.Visibility = Visibility.Collapsed; + + _gitPanes[vm.Id] = new GitPaneUi + { + Chip = chip, ChipText = chipText, Panel = panel, PanelHeader = header, PanelText = text, + }; + + vm.PropertyChanged += (_, e) => + { + if (e.PropertyName is nameof(SessionViewModel.GitInfoVersion) or nameof(SessionViewModel.RepoRoot)) + Dispatcher.Invoke(() => UpdateGitChip(vm)); + }; + UpdateGitChip(vm); + return (chip, panel); + } + + /// Repaints one pane's chip from the VM. Cheap; called on every git notification. + private void UpdateGitChip(SessionViewModel vm) + { + if (!_gitPanes.TryGetValue(vm.Id, out var ui)) return; + + // SSH has no local folder to run git in (an OSC 9001 branch is display-only), and a + // folder that isn't a repo gets nothing. + bool show = vm.Session.Kind != SessionKind.Ssh + && vm.GitInfoLoaded + && (!string.IsNullOrEmpty(vm.GitBranch) || !string.IsNullOrEmpty(vm.RepoRoot)); + ui.Chip.Visibility = show ? Visibility.Visible : Visibility.Collapsed; + if (!show) return; + + bool busy = _gitBusyRepos.Contains(GitRepoKey(vm)); + var t = ui.ChipText; + t.Inlines.Clear(); + // Capped by hand rather than TextTrimming, which would trim from the END and eat the + // ↑/↓ counts first. The tooltip carries the full name. + string branch = vm.GitBranch ?? "detached"; + if (branch.Length > MaxChipBranchChars) branch = branch[..(MaxChipBranchChars - 1)] + "…"; + t.Inlines.Add(new Run((busy ? "⟳ " : "⎇ ") + branch) { Foreground = GitMuted }); + if (vm.GitIsDirty) t.Inlines.Add(new Run(" ●") { Foreground = GitDirty }); + string sync = GitStatusText.Sync(vm.GitAhead, vm.GitBehind); + if (sync.Length > 0) t.Inlines.Add(new Run(" " + sync) { Foreground = GitSync }); + + ui.Chip.ToolTip = GitStatusText.MenuHeader( + vm.GitBranch, vm.GitUpstream, vm.GitAhead, vm.GitBehind, vm.GitChangeCount); + } + + private void UpdateGitChipsForRepo(string repoKey) + { + foreach (var s in _vm.Sessions) + if (string.Equals(GitRepoKey(s), repoKey, StringComparison.OrdinalIgnoreCase)) + UpdateGitChip(s); + } + + private void ShowGitMenu(SessionViewModel vm, WpfButton anchor) + { + // Show what we know now, and re-read in the background so the chip catches up. + _ = vm.RefreshGitInfoAsync(); + + bool busy = _gitBusyRepos.Contains(GitRepoKey(vm)); + var menu = new ContextMenu + { + PlacementTarget = anchor, + Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom, + }; + + // Headers carrying a branch name are TextBlocks, not strings: a string Header is parsed + // for access keys, so "fix_x" would render as "fixx" with the x underlined. + menu.Items.Add(new MenuItem + { + Header = new TextBlock + { + Text = GitStatusText.MenuHeader(vm.GitBranch, vm.GitUpstream, vm.GitAhead, vm.GitBehind, vm.GitChangeCount), + }, + IsEnabled = false, + }); + menu.Items.Add(new Separator()); + + var fetch = new MenuItem { Header = busy ? "Fetch (running…)" : "Fetch", IsEnabled = !busy }; + fetch.Click += async (_, _) => await FetchAsync(vm); + menu.Items.Add(fetch); + + bool hasUpstream = !string.IsNullOrEmpty(vm.GitUpstream); + var pull = new MenuItem + { + Header = vm.GitBehind is > 0 ? $"Pull ↓{vm.GitBehind} (fast-forward only)" : "Pull (fast-forward only)", + IsEnabled = !busy && hasUpstream, + ToolTip = hasUpstream ? null : "This branch has no upstream to pull from.", + }; + ToolTipService.SetShowOnDisabled(pull, true); + pull.Click += async (_, _) => await PullAsync(vm); + menu.Items.Add(pull); + + var status = new MenuItem { Header = "Status…" }; + status.Click += async (_, _) => await ShowGitStatusAsync(vm); + menu.Items.Add(status); + + var switchMenu = new MenuItem { Header = "Switch branch", IsEnabled = !busy }; + switchMenu.Items.Add(new MenuItem { Header = "Loading…", IsEnabled = false }); + bool loaded = false; + switchMenu.SubmenuOpened += async (_, _) => + { + if (loaded) return; + loaded = true; + var branches = await GitService.ListBranchesAsync(vm.WorkingFolder); + switchMenu.Items.Clear(); + if (branches.Count == 0) + { + switchMenu.Items.Add(new MenuItem { Header = "(no local branches)", IsEnabled = false }); + return; + } + foreach (string b in branches) + { + bool current = b == vm.GitBranch; + var mi = new MenuItem + { + Header = new TextBlock { Text = b }, + IsCheckable = false, + IsChecked = current, + IsEnabled = !current, + }; + string target = b; + mi.Click += async (_, _) => await SwitchBranchAsync(vm, target); + switchMenu.Items.Add(mi); + } + }; + menu.Items.Add(switchMenu); + + menu.Items.Add(new Separator()); + + var copy = new MenuItem { Header = "Copy branch name", IsEnabled = !string.IsNullOrEmpty(vm.GitBranch) }; + copy.Click += (_, _) => { try { Clipboard.SetText(vm.GitBranch ?? ""); } catch { } }; + menu.Items.Add(copy); + + if (_vsCodeLookup is { IsCompletedSuccessfully: true, Result: { } codeExe }) + { + var code = new MenuItem { Header = "Open in VS Code" }; + string folder = vm.WorkingFolder; + code.Click += (_, _) => _ = Task.Run(() => + { + try + { + // Code.exe directly with the folder as one argv element — not code.cmd, + // which would put the folder through cmd.exe's parser. + var psi = new System.Diagnostics.ProcessStartInfo(codeExe) { UseShellExecute = false }; + psi.ArgumentList.Add(folder); + System.Diagnostics.Process.Start(psi)?.Dispose(); + } + catch (Exception ex) + { + Dispatcher.BeginInvoke(() => ToastHelper.Show("Couldn't open VS Code", ex.Message)); + } + }); + menu.Items.Add(code); + } + + menu.IsOpen = true; + } + + // ── operations ────────────────────────────────────────────────────────────────── + + private async Task FetchAsync(SessionViewModel vm) + { + var r = await RunGitOpAsync(vm, "fetch", GitService.FetchAsync); + if (r is not { Ok: true }) return; + ToastHelper.Show("git fetch", + GitStatusText.FetchSummary(vm.GitBranch, vm.GitUpstream, vm.GitAhead, vm.GitBehind)); + } + + private async Task PullAsync(SessionViewModel vm) + { + var r = await RunGitOpAsync(vm, "pull", GitService.PullFastForwardAsync); + if (r is not { Ok: true }) return; + ToastHelper.Show($"git pull — {vm.GitBranch}", GitService.SummarizePull(r.Output)); + } + + private async Task SwitchBranchAsync(SessionViewModel vm, string branch) + { + var r = await RunGitOpAsync(vm, "switch", f => GitService.SwitchBranchAsync(f, branch)); + if (r is not { Ok: true }) return; + ToastHelper.Show("git switch", $"Switched to {branch}."); + } + + /// + /// Runs one fetch/pull/switch: claims the repo's busy slot, runs it off the UI thread, + /// re-reads git state for every pane on the repo, and on failure opens the panel with + /// git's own output. Returns null when the repo was already busy. + /// + private async Task RunGitOpAsync( + SessionViewModel vm, string verb, Func> op) + { + string key = GitRepoKey(vm); + if (!_gitBusyRepos.Add(key)) + { + ToastHelper.Show("Git busy", "A git operation is already running for this repository."); + return null; + } + UpdateGitChipsForRepo(key); + + GitOpResult result; + try + { + result = await op(vm.WorkingFolder); + } + finally + { + _gitBusyRepos.Remove(key); + } + + // A fetch moves only remote refs, which GitRepoWatcher (HEAD + index) never sees, so + // every pane on this repo — worktrees included, they share refs — re-reads explicitly. + var peers = _vm.Sessions + .Where(s => string.Equals(GitRepoKey(s), key, StringComparison.OrdinalIgnoreCase)) + .ToList(); + await Task.WhenAll(peers.Select(s => s.RefreshGitInfoAsync())); + UpdateGitChipsForRepo(key); + + if (!result.Ok) + { + string? hint = GitStatusText.FailureHint(result.Output); + ShowGitPanel(vm, $"git {verb} failed" + (hint is null ? "" : $" — {hint}"), + string.IsNullOrWhiteSpace(result.Output) ? "(git printed nothing)" : result.Output); + ToastHelper.Show($"git {verb} failed", hint ?? "See the git panel for details."); + } + return result; + } + + private async Task ShowGitStatusAsync(SessionViewModel vm) + { + ShowGitPanel(vm, "git status", "…"); + var r = await GitService.StatusTextAsync(vm.WorkingFolder); + ShowGitPanel(vm, "git status", string.IsNullOrWhiteSpace(r.Output) ? "(git printed nothing)" : r.Output); + } + + private void ShowGitPanel(SessionViewModel vm, string header, string body) + { + if (!_gitPanes.TryGetValue(vm.Id, out var ui)) return; + ui.PanelHeader.Text = header; + ui.PanelHeader.ToolTip = header; + ui.PanelText.Text = body.Replace("\n", Environment.NewLine); + ui.PanelText.ScrollToHome(); + ui.Panel.Visibility = Visibility.Visible; + } + + /// + /// Finds VS Code's Code.exe via the code.cmd launcher on PATH (it lives in + /// <install>\bin\). File checks only — no process is spawned to find it. + /// + private static string? FindVsCode() + { + try + { + string path = Environment.GetEnvironmentVariable("PATH") ?? ""; + foreach (string dir in path.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + string d = dir.Trim().Trim('"'); + if (d.Length == 0 || d.StartsWith(@"\\", StringComparison.Ordinal)) continue; // skip network dirs + string cmd = System.IO.Path.Combine(d, "code.cmd"); + if (!System.IO.File.Exists(cmd)) continue; + string exe = System.IO.Path.GetFullPath(System.IO.Path.Combine(d, "..", "Code.exe")); + if (System.IO.File.Exists(exe)) return exe; + } + } + catch { } + return null; + } +} diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 50f5833..994b78a 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -1584,6 +1584,9 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal // session stayed in SessionManager with no row — persisted, invisible. Log($"LaunchSession WebView2 FAILED: {ex}"); TerminalGrid.Children.Remove(terminalWrapper); + // BuildTerminalWrapper registered these; the pane is gone, so drop them too. + _runControls.Remove(vm.Id); + _gitPanes.Remove(vm.Id); vm.Dispose(); if (_launchingSidebarItems.Remove(session.Id)) RebuildSidebarOrder(); @@ -1716,6 +1719,9 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal { Log($"PTY start FAILED: {ex}"); TerminalGrid.Children.Remove(terminalWrapper); + // BuildTerminalWrapper registered these; the pane is gone, so drop them too. + _runControls.Remove(vm.Id); + _gitPanes.Remove(vm.Id); MessageBox.Show($"Failed to start '{session.FullCommandLine}':\n{ex.Message}", "Launch Error", MessageBoxButton.OK, MessageBoxImage.Error); vm.Dispose(); @@ -4580,6 +4586,10 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) }; chevronBtn.Click += (_, _) => ShowRunCommandsDropdown(vm, chevronBtn); + // Git chip + its output panel (MainWindow.Git.cs). Hidden until the folder is known + // to be a repo. + var (gitChip, gitPanel) = BuildGitControls(vm); + // Edit session — opens the New Session form pre-filled with this session's config. var editBtn = new WpfButton { @@ -4655,6 +4665,7 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) DockPanel.SetDock(editBtn, Dock.Right); DockPanel.SetDock(chevronBtn, Dock.Right); DockPanel.SetDock(playBtn, Dock.Right); + DockPanel.SetDock(gitChip, Dock.Right); DockPanel.SetDock(claudeBadge, Dock.Left); DockPanel.SetDock(titleBlock, Dock.Left); // Dock.Right children are stacked right-to-left in declaration order, so this @@ -4669,6 +4680,7 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) toolbarContent.Children.Add(editBtn); toolbarContent.Children.Add(chevronBtn); toolbarContent.Children.Add(playBtn); + toolbarContent.Children.Add(gitChip); toolbarContent.Children.Add(claudeBadge); toolbarContent.Children.Add(titleBlock); toolbarContent.Children.Add(folderBlock); @@ -4826,9 +4838,11 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) DockPanel.SetDock(chipsStrip, Dock.Top); DockPanel.SetDock(drawer, Dock.Top); DockPanel.SetDock(notesPanel, Dock.Top); + DockPanel.SetDock(gitPanel, Dock.Top); outer.Children.Add(toolbar); outer.Children.Add(chipsStrip); outer.Children.Add(drawer); + outer.Children.Add(gitPanel); outer.Children.Add(notesPanel); outer.Children.Add(webView); wrapper.Child = outer; @@ -4928,6 +4942,7 @@ private void OnSessionVmClosed(SessionViewModel vm) _sessionUiVersion++; } _runControls.Remove(vm.Id); + _gitPanes.Remove(vm.Id); _drawerItemBySession.Remove(vm.Id); _drawerShown.Remove(vm.Id); _sidebarActionPanels.Remove(vm.Id); @@ -5223,6 +5238,7 @@ private async Task RestartSessionCoreAsync(SessionViewModel vm, string? launched _sessionUiVersion++; } _runControls.Remove(vm.Id); + _gitPanes.Remove(vm.Id); _drawerItemBySession.Remove(vm.Id); _drawerShown.Remove(vm.Id); _sidebarActionPanels.Remove(vm.Id); @@ -5359,6 +5375,7 @@ private void SleepSession(SessionViewModel vm) _sessionUiVersion++; } _runControls.Remove(vm.Id); + _gitPanes.Remove(vm.Id); _drawerItemBySession.Remove(vm.Id); _drawerShown.Remove(vm.Id); _sidebarActionPanels.Remove(vm.Id); diff --git a/src/CodeShellManager/Services/GitService.cs b/src/CodeShellManager/Services/GitService.cs index 9042bcd..5812af7 100644 --- a/src/CodeShellManager/Services/GitService.cs +++ b/src/CodeShellManager/Services/GitService.cs @@ -10,26 +10,177 @@ namespace CodeShellManager.Services; public static class GitService { + /// + /// Branch + dirty, as the sidebar and the git poll have always consumed them. A detached + /// HEAD reports (null, false), exactly as the old branch --show-current pair did. + /// public static async Task<(string? branch, bool isDirty)> GetGitInfoAsync(string folderPath) { - if (string.IsNullOrWhiteSpace(folderPath) || !System.IO.Directory.Exists(folderPath)) + var status = await GetStatusAsync(folderPath).ConfigureAwait(false); + if (status is null || status.IsDetached || string.IsNullOrEmpty(status.Branch)) return (null, false); + return (status.Branch, status.IsDirty); + } + /// + /// Branch, upstream, ahead/behind and changed files in ONE git spawn + /// (status --porcelain=v2 --branch). The poll used to spend two spawns on + /// branch --show-current + status --porcelain, and a spawn is ~all of the + /// cost (see "Never spawn a process on the UI thread"), so this is cheaper as well as + /// richer. Null when the folder isn't in a repo or git failed. + /// + /// Runs with GIT_OPTIONAL_LOCKS=0: this is a background read, and a plain + /// git status opportunistically takes index.lock to refresh stat data — which + /// is how a poll makes a git command the user (or Claude) runs at the same moment fail with + /// "index.lock exists". + /// + public static async Task GetStatusAsync(string folderPath) + { + if (string.IsNullOrWhiteSpace(folderPath) || !System.IO.Directory.Exists(folderPath)) + return null; try { - string? branch = await RunGitAsync(folderPath, "branch", "--show-current").ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(branch)) - return (null, false); + var (stdout, _, exit) = await RunGitFullAsync(folderPath, + new[] { "-c", "core.quotePath=false", "status", "--porcelain=v2", "--branch" }, + timeoutMs: 3000, readOnly: true).ConfigureAwait(false); + return exit == 0 ? ParseStatusV2(stdout) : null; + } + catch { return null; } + } - string? statusOutput = await RunGitAsync(folderPath, "status", "--porcelain").ConfigureAwait(false); - bool isDirty = !string.IsNullOrWhiteSpace(statusOutput); + /// + /// Parses git status --porcelain=v2 --branch. Pure, so it is unit-tested against + /// captured output rather than a live repo. + /// + internal static GitRepoStatus ParseStatusV2(string output) + { + string? head = null, upstream = null; + int? ahead = null, behind = null; + var changes = new List(); - return (branch.Trim(), isDirty); + foreach (string line in (output ?? "").Replace("\r", "").Split('\n')) + { + if (line.Length == 0) continue; + if (line.StartsWith("# branch.head ", StringComparison.Ordinal)) + head = line["# branch.head ".Length..]; + else if (line.StartsWith("# branch.upstream ", StringComparison.Ordinal)) + upstream = line["# branch.upstream ".Length..]; + else if (line.StartsWith("# branch.ab ", StringComparison.Ordinal)) + { + // "# branch.ab + -" + var parts = line["# branch.ab ".Length..].Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 2 + && int.TryParse(parts[0].TrimStart('+'), out int a) + && int.TryParse(parts[1].TrimStart('-'), out int b)) + { ahead = a; behind = b; } + } + else if (line[0] == '#') continue; + else if (line.StartsWith("? ", StringComparison.Ordinal)) + changes.Add(new GitFileChange("??", line[2..])); + else if (line.StartsWith("! ", StringComparison.Ordinal)) + continue; // ignored files — only listed with --ignored, never by us + else + { + // 1 XY sub mH mI mW hH hI + // 2 XY sub mH mI mW hH hI Xscore \t + // u XY sub m1 m2 m3 mW h1 h2 h3 + int fieldsBeforePath = line[0] switch { '1' => 8, '2' => 9, 'u' => 10, _ => -1 }; + if (fieldsBeforePath < 0) continue; + var f = line.Split(' ', fieldsBeforePath + 1); + if (f.Length <= fieldsBeforePath || f[1].Length != 2) continue; + string path = f[fieldsBeforePath]; + if (line[0] == '2') + { + int tab = path.IndexOf('\t'); + if (tab >= 0) path = $"{path[(tab + 1)..]} → {path[..tab]}"; + } + changes.Add(new GitFileChange(line[0] == 'u' ? "UU" : f[1].Replace('.', ' '), path)); + } } - catch + + bool detached = head == "(detached)"; + return new GitRepoStatus(detached ? null : head, detached, upstream, ahead, behind, changes); + } + + // ── User-initiated operations (the toolbar git menu) ───────────────────────────── + // + // None of these ever run unprompted, and none of them write history: no commit, no push, + // no merge beyond a fast-forward. A Claude session is usually editing this same working + // tree, and a one-click history write from outside its conversation is both a race and + // a misclick that cannot be taken back. + + /// git fetch. Network-bound, so a long timeout; never prompts. + public static Task FetchAsync(string folderPath) => + RunOpAsync(folderPath, new[] { "fetch" }, timeoutMs: 60_000); + + /// + /// git pull --ff-only --no-rebase: moves the branch only when that loses nothing. + /// --no-rebase is belt-and-braces against a user's pull.rebase=true turning a + /// one-click "pull" into a rebase of their local commits. git 2.54 already lets + /// --ff-only win (the diverged-branch test passes without the flag), but how the two + /// interact has changed across git versions, and the flag costs nothing. If the branch has diverged + /// or a local edit would be overwritten, git refuses and says why — we report that rather + /// than stashing or merging on the user's behalf. + /// + public static Task PullFastForwardAsync(string folderPath) => + RunOpAsync(folderPath, new[] { "pull", "--ff-only", "--no-rebase" }, timeoutMs: 120_000); + + /// + /// git switch --no-guess -- <branch>. The name comes from the repo + /// (), and a ref can legally be called -x: -- + /// keeps it a branch name rather than an option (verified against git 2.54 with a branch + /// literally named -x). --no-guess stops git from silently creating a + /// tracking branch when the name only exists on a remote. + /// + public static Task SwitchBranchAsync(string folderPath, string branch) => + RunOpAsync(folderPath, SwitchArgs(branch), timeoutMs: 30_000); + + internal static string[] SwitchArgs(string branch) => new[] { "switch", "--no-guess", "--", branch }; + + /// Human-readable git status for the git panel. + public static Task StatusTextAsync(string folderPath) => + RunOpAsync(folderPath, new[] { "-c", "color.status=false", "status" }, timeoutMs: 10_000, readOnly: true); + + private static async Task RunOpAsync( + string folderPath, string[] args, int timeoutMs, bool readOnly = false) + { + if (string.IsNullOrWhiteSpace(folderPath) || !System.IO.Directory.Exists(folderPath)) + return new GitOpResult(false, "Folder does not exist.", TimedOut: false); + try { - return (null, false); + var (stdout, stderr, exit) = await RunGitFullAsync(folderPath, args, timeoutMs, readOnly) + .ConfigureAwait(false); + bool timedOut = exit == TimedOutExit; + string text = timedOut + ? $"git {args.First(a => !a.StartsWith('-') && !a.Contains('='))} did not finish within " + + $"{timeoutMs / 1000}s and was stopped. If the remote needs a password or key " + + "passphrase, run the command in a terminal once." + : CombineOutput(stdout, stderr); + return new GitOpResult(exit == 0, text, timedOut); } + catch (Exception ex) { return new GitOpResult(false, ex.Message, TimedOut: false); } + } + + /// git writes progress and most of its prose to stderr, even on success. + internal static string CombineOutput(string stdout, string stderr) => + string.Join("\n", new[] { stdout, stderr } + .Select(s => (s ?? "").Replace("\r\n", "\n").Trim()) + .Where(s => s.Length > 0)); + + /// + /// One line for a toast after a successful pull: "Already up to date." or the diffstat + /// summary ("3 files changed, 10 insertions(+)"). + /// + internal static string SummarizePull(string output) + { + var lines = (output ?? "").Replace("\r", "").Split('\n') + .Select(l => l.Trim()).Where(l => l.Length > 0).ToList(); + if (lines.Any(l => l.StartsWith("Already up to date", StringComparison.OrdinalIgnoreCase))) + return "Already up to date."; + var stat = lines.LastOrDefault(l => Regex.IsMatch(l, @"^\d+ files? changed")); + if (stat != null) return $"Fast-forwarded: {stat}"; + return lines.Any(l => l.Equals("Fast-forward", StringComparison.OrdinalIgnoreCase)) + ? "Fast-forwarded." : "Pull finished."; } /// @@ -175,6 +326,51 @@ public static async Task> ListBranchesAsync(string folderP return exit == 0 ? stdout : null; } + /// Exit code reported when a git run was killed for exceeding its timeout. + internal const int TimedOutExit = -2; + + /// + /// Environment for every git we spawn. + /// + /// GIT_TERMINAL_PROMPT=0: git runs with no console, so a credential prompt for an + /// HTTPS remote would wait for input that can never arrive until the timeout kills it. With + /// it, git fails at once with "terminal prompts disabled", which we can report. (A GUI + /// credential helper such as Git Credential Manager still works — it doesn't use the + /// terminal.) + /// + /// GIT_OPTIONAL_LOCKS=0 for read-only calls: see . + /// + internal static IReadOnlyList> GitEnvironment(bool readOnly) + { + var env = new List> { new("GIT_TERMINAL_PROMPT", "0") }; + if (readOnly) env.Add(new("GIT_OPTIONAL_LOCKS", "0")); + return env; + } + + /// + /// WSLENV with added. Windows environment variables only cross + /// into a distro when WSLENV lists them, so without this the git inside WSL never saw + /// GIT_TERMINAL_PROMPT. Existing entries (and their /p-style flags) are kept. + /// + internal static string MergeWslEnv(string? existing, IEnumerable names) + { + var list = (existing ?? "").Split(':', StringSplitOptions.RemoveEmptyEntries).ToList(); + foreach (string n in names) + if (!list.Any(e => e.Split('/')[0] == n)) list.Add(n); + return string.Join(":", list); + } + + private static void ApplyEnvironment(ProcessStartInfo psi, bool readOnly, bool forWsl) + { + var env = GitEnvironment(readOnly); + foreach (var (k, v) in env) psi.Environment[k] = v; + if (forWsl) + { + psi.Environment.TryGetValue("WSLENV", out var existing); + psi.Environment["WSLENV"] = MergeWslEnv(existing, env.Select(e => e.Key)); + } + } + /// /// Joins argv into a Windows command line, quoting each element with the MSVCRT rules. /// @@ -261,18 +457,18 @@ internal static string BuildLocalGitCommandLine( /// see the note on Process.Start below (issue #70). /// private static Task<(string stdout, string stderr, int exit)> RunGitFullAsync( - string workingDir, IReadOnlyList args, int timeoutMs) - => Task.Run(() => RunGitCoreAsync(workingDir, args, timeoutMs)); + string workingDir, IReadOnlyList args, int timeoutMs, bool readOnly = false) + => Task.Run(() => RunGitCoreAsync(workingDir, args, timeoutMs, readOnly)); private static async Task<(string stdout, string stderr, int exit)> RunGitCoreAsync( - string workingDir, IReadOnlyList args, int timeoutMs) + string workingDir, IReadOnlyList args, int timeoutMs, bool readOnly) { // WSL working folders (\\wsl$\\…) get routed through wsl.exe so git // runs inside the distro. Git for Windows trips on WSL UNCs (dubious-ownership // checks, .git symlink quirks) and reports "not a git repo" for valid repos. var (wslDistro, linuxPath) = TryParseWslUnc(workingDir); if (wslDistro != null) - return await RunGitInWslAsync(wslDistro, linuxPath, args, timeoutMs).ConfigureAwait(false); + return await RunGitInWslAsync(wslDistro, linuxPath, args, timeoutMs, readOnly).ConfigureAwait(false); var psi = new ProcessStartInfo("git") { @@ -282,6 +478,7 @@ internal static string BuildLocalGitCommandLine( UseShellExecute = false, CreateNoWindow = true }; + ApplyEnvironment(psi, readOnly, forWsl: false); // Process.Start is synchronous and sits BEFORE this method's first await, so without // the Task.Run above it ran on whatever thread called in. Every caller chain here @@ -314,14 +511,18 @@ internal static string BuildLocalGitCommandLine( var bothTask = Task.WhenAll(outTask, errTask); var completed = await Task.WhenAny(bothTask, Task.Delay(timeoutMs)).ConfigureAwait(false); - if (completed != bothTask) + // The whole tree: a fetch's real work happens in children (git-remote-https, ssh), + // and killing only git.exe left those running. + bool timedOut = completed != bothTask; + if (timedOut) { - try { process.Kill(); } catch { } + try { process.Kill(entireProcessTree: true); } catch { } } try { await process.WaitForExitAsync().ConfigureAwait(false); } catch { } string stdout = outTask.IsCompletedSuccessfully ? outTask.Result : ""; string stderr = errTask.IsCompletedSuccessfully ? errTask.Result : ""; + if (timedOut) return (stdout, stderr, TimedOutExit); return (stdout, stderr, process.HasExited ? process.ExitCode : -1); } @@ -333,7 +534,7 @@ internal static string BuildLocalGitCommandLine( /// back to UNC form so callers receive Windows-shaped paths. /// private static async Task<(string stdout, string stderr, int exit)> RunGitInWslAsync( - string distro, string linuxPath, IReadOnlyList gitArgs, int timeoutMs) + string distro, string linuxPath, IReadOnlyList gitArgs, int timeoutMs, bool readOnly) { string cwd = string.IsNullOrEmpty(linuxPath) ? "/" : linuxPath; @@ -363,6 +564,7 @@ internal static string BuildLocalGitCommandLine( StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; + ApplyEnvironment(psi, readOnly, forWsl: true); using var process = Process.Start(psi); if (process is null) return ("", "", -1); @@ -370,9 +572,10 @@ internal static string BuildLocalGitCommandLine( var outTask = process.StandardOutput.ReadToEndAsync(); var errTask = process.StandardError.ReadToEndAsync(); var bothTask = Task.WhenAll(outTask, errTask); - var completed = await Task.WhenAny(bothTask, Task.Delay(timeoutMs)); - if (completed != bothTask) { try { process.Kill(); } catch { } } - try { await process.WaitForExitAsync(); } catch { } + var completed = await Task.WhenAny(bothTask, Task.Delay(timeoutMs)).ConfigureAwait(false); + bool timedOut = completed != bothTask; + if (timedOut) { try { process.Kill(entireProcessTree: true); } catch { } } + try { await process.WaitForExitAsync().ConfigureAwait(false); } catch { } string stdout = outTask.IsCompletedSuccessfully ? outTask.Result : ""; string stderr = errTask.IsCompletedSuccessfully ? errTask.Result : ""; @@ -380,6 +583,7 @@ internal static string BuildLocalGitCommandLine( // paths back to UNC. Order matters: the sentinel must go before path translation, // or a banner containing a slash would be rewritten as if it were a git path. stdout = TranslateLinuxPathsToUnc(StripWslProfileNoise(stdout), distro); + if (timedOut) return (stdout, stderr, TimedOutExit); return (stdout, stderr, process.HasExited ? process.ExitCode : -1); } @@ -439,3 +643,21 @@ internal static string TranslateLinuxPathsToUnc(string text, string distro) }, RegexOptions.Multiline); } } + +/// One changed path from git status. is the two-letter XY +/// code ("M ", " M", "??", "UU" …); a rename's path reads "old → new". +public sealed record GitFileChange(string Code, string Path); + +/// A repo snapshot from one git status --porcelain=v2 --branch. +/// Null when detached. +/// e.g. "origin/main"; null when the branch tracks nothing. +/// Null when there is no upstream (or it is gone). +public sealed record GitRepoStatus( + string? Branch, bool IsDetached, string? Upstream, int? Ahead, int? Behind, + IReadOnlyList Changes) +{ + public bool IsDirty => Changes.Count > 0; +} + +/// Outcome of a user-initiated git operation; is stdout + stderr. +public sealed record GitOpResult(bool Ok, string Output, bool TimedOut); diff --git a/src/CodeShellManager/Services/GitStatusText.cs b/src/CodeShellManager/Services/GitStatusText.cs new file mode 100644 index 0000000..78d587f --- /dev/null +++ b/src/CodeShellManager/Services/GitStatusText.cs @@ -0,0 +1,79 @@ +using System; + +namespace CodeShellManager.Services; + +/// +/// Wording for the terminal toolbar's git chip and menu. WPF-free so the strings — which are +/// most of what the user actually sees of this feature — are unit-testable. +/// +public static class GitStatusText +{ + /// "↑2 ↓5", "↑2", "↓5", or "" when level with (or not tracking) an upstream. + public static string Sync(int? ahead, int? behind) + { + string up = ahead is > 0 ? $"↑{ahead}" : ""; + string down = behind is > 0 ? $"↓{behind}" : ""; + return string.Join(" ", new[] { up, down }).Trim(); + } + + /// + /// The disabled first line of the menu, e.g. "main → origin/main · ↑2 ↓5 · 3 changed" + /// or "main · no upstream · clean". + /// + public static string MenuHeader(string? branch, string? upstream, int? ahead, int? behind, int changeCount) + { + string head = string.IsNullOrEmpty(branch) ? "detached HEAD" : branch; + string track = string.IsNullOrEmpty(upstream) ? " · no upstream" : $" → {upstream}"; + string sync = Sync(ahead, behind); + // git omits "# branch.ab" when the upstream is configured but its remote branch no + // longer exists (deleted after merge, say) — that is not "up to date". + if (!string.IsNullOrEmpty(upstream) && sync.Length == 0) + sync = ahead is null ? "upstream gone" : "up to date"; + string changes = changeCount switch + { + 0 => "clean", + 1 => "1 changed", + _ => $"{changeCount} changed", + }; + return $"{head}{track}" + (sync.Length > 0 ? $" · {sync}" : "") + $" · {changes}"; + } + + /// Toast text after a successful fetch, from the freshly re-read status. + public static string FetchSummary(string? branch, string? upstream, int? ahead, int? behind) + { + if (string.IsNullOrEmpty(branch) || string.IsNullOrEmpty(upstream) || behind is null) + return "Fetched."; + if (behind == 0 && ahead is null or 0) return $"{branch} is up to date with {upstream}."; + if (behind == 0) return $"Nothing new — {branch} is {ahead} ahead of {upstream}."; + string commits = behind == 1 ? "1 new commit" : $"{behind} new commits"; + return ahead is > 0 + ? $"{commits} on {upstream}; {branch} has diverged ({ahead} ahead)." + : $"{commits} on {upstream} — Pull to fast-forward."; + } + + /// + /// A short, actionable reason for a failed fetch/pull/switch, recognised from git's own + /// message; null when the message is not one we know (the panel then shows git's text). + /// + public static string? FailureHint(string output) + { + string o = output ?? ""; + bool Has(string s) => o.Contains(s, StringComparison.OrdinalIgnoreCase); + + if (Has("Not possible to fast-forward") || Has("diverging branches") || Has("have diverged")) + return "The branch has diverged from its upstream, so it can't fast-forward. Merge or rebase in a terminal."; + if (Has("would be overwritten")) + return "Local changes would be overwritten. Commit or stash them first."; + if (Has("terminal prompts disabled") || Has("could not read Username") || Has("Authentication failed")) + return "The remote needs credentials. Run git fetch once in a terminal to sign in."; + if (Has("Permission denied (publickey)") || Has("Host key verification failed")) + return "SSH authentication failed. Check your key or ssh-agent."; + if (Has("no tracking information") || Has("There is no tracking information")) + return "This branch has no upstream to pull from."; + if (Has("Could not resolve host") || Has("unable to access")) + return "Couldn't reach the remote. Check the network."; + if (Has("index.lock")) + return "Another git command is running in this repo. Try again in a moment."; + return null; + } +} diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index 80c3081..910fb97 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -41,6 +41,16 @@ public bool GitInfoLoaded get => _gitInfoLoaded; set { if (_gitInfoLoaded != value) { _gitInfoLoaded = value; BumpGitInfo(); } } } + // Sync state for the toolbar git chip. Same rule as the three above: plain fields, + // announced only through GitInfoVersion. + /// Tracking branch ("origin/main"), or null when the branch tracks nothing. + public string? GitUpstream { get; private set; } + /// Commits ahead of / behind ; null without an upstream. + public int? GitAhead { get; private set; } + public int? GitBehind { get; private set; } + /// Number of changed paths (staged, unstaged and untracked). + public int GitChangeCount { get; private set; } + /// Absolute path to the session's repo top-level, or null if the working folder is not in a git repo. [ObservableProperty] private string? _repoRoot; /// Set by MainWindow whenever another live session shares this session's RepoRoot. @@ -153,9 +163,15 @@ public async Task RefreshGitInfoAsync() // the synchronous Directory.Exists prefix that sits outside it, and keeping the // resume on the UI thread is what lets the sets below notify WPF safely. string folder = Session.WorkingFolder; - var (branch, isDirty) = await Task.Run(() => GitService.GetGitInfoAsync(folder)); + // One spawn for branch, dirty, upstream and ahead/behind (it used to be two for just + // branch + dirty). A detached HEAD keeps reporting "no branch, clean" as it always has. + var status = await Task.Run(() => GitService.GetStatusAsync(folder)); if (token.IsCancellationRequested) return; // session closed while the probe was off-thread - ApplyGitInfo(branch, isDirty); + if (status is null || status.IsDetached || string.IsNullOrEmpty(status.Branch)) + ApplyGitInfo(null, false); + else + ApplyGitInfo(status.Branch, status.IsDirty, status.Upstream, + status.Ahead, status.Behind, status.Changes.Count); // RepoRoot is stable for the life of the session — resolve it once. Don't gate on // a non-empty branch: detached HEADs report no branch but are still valid repos @@ -181,14 +197,24 @@ public async Task RefreshGitInfoAsync() /// so the overwhelmingly common poll result is "identical to last time", and that now /// costs no UI work at all. /// - public void ApplyGitInfo(string? branch, bool isDirty) + public void ApplyGitInfo(string? branch, bool isDirty) => + ApplyGitInfo(branch, isDirty, upstream: null, ahead: null, behind: null, changeCount: isDirty ? 1 : 0); + + /// + public void ApplyGitInfo(string? branch, bool isDirty, string? upstream, int? ahead, int? behind, int changeCount) { - if (_gitInfoLoaded && _gitBranch == branch && _gitIsDirty == isDirty) return; + if (_gitInfoLoaded && _gitBranch == branch && _gitIsDirty == isDirty + && GitUpstream == upstream && GitAhead == ahead && GitBehind == behind + && GitChangeCount == changeCount) return; // Backing fields directly — the generated setters would raise one event each. _gitBranch = branch; _gitIsDirty = isDirty; _gitInfoLoaded = true; + GitUpstream = upstream; + GitAhead = ahead; + GitBehind = behind; + GitChangeCount = changeCount; BumpGitInfo(); } @@ -400,6 +426,10 @@ public Task ReloadGitInfoAsync() GitBranch = null; GitIsDirty = false; GitInfoLoaded = false; + GitUpstream = null; + GitAhead = null; + GitBehind = null; + GitChangeCount = 0; HasWorktreeSiblings = false; // The user just pointed this session at a different folder, so whatever program // pushed git info via OSC 9001 was describing the old one. Let the local poller diff --git a/tests/CodeShellManager.Tests/GitToolsTests.cs b/tests/CodeShellManager.Tests/GitToolsTests.cs new file mode 100644 index 0000000..48e18f9 --- /dev/null +++ b/tests/CodeShellManager.Tests/GitToolsTests.cs @@ -0,0 +1,414 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// The toolbar git menu: status parsing, the wording it shows, the argv it builds, and the +/// operations themselves against a real throwaway repo with a local "remote". +/// +public class GitStatusParseTests +{ + // Captured verbatim from git 2.54 (`-c core.quotePath=false status --porcelain=v2 --branch`) + // on a repo with a modified file, a path with a space, a staged rename, a staged non-ASCII + // path inside a spaced directory, and an untracked file. + private const string Captured = + "# branch.oid d168ec5ccba03426d0799f256ccb5f08c53d24b2\n" + + "# branch.head master\n" + + "# branch.upstream origin/master\n" + + "# branch.ab +1 -3\n" + + "1 .M N... 100644 100644 100644 78981922613b2afb6025042ff6bd878ac1994e85 78981922613b2afb6025042ff6bd878ac1994e85 a.txt\n" + + "1 .M N... 100644 100644 100644 61780798228d17af2d34fce4cfbdf35556832472 61780798228d17af2d34fce4cfbdf35556832472 my file.txt\n" + + "2 R. N... 100644 100644 100644 f2ad6c76f0115a6ba5b00456a849810e7ec0af20 f2ad6c76f0115a6ba5b00456a849810e7ec0af20 R100 new.txt\told.txt\n" + + "1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 bca70f35318f31dd1d1d1d2d2e64c19b880899ff sub dir/æø.txt\n" + + "? untracked.txt\n"; + + [Fact] + public void Parses_branch_upstream_and_ahead_behind() + { + var s = GitService.ParseStatusV2(Captured); + Assert.Equal("master", s.Branch); + Assert.False(s.IsDetached); + Assert.Equal("origin/master", s.Upstream); + Assert.Equal(1, s.Ahead); + Assert.Equal(3, s.Behind); + } + + [Fact] + public void Parses_every_change_with_its_full_path() + { + var s = GitService.ParseStatusV2(Captured); + Assert.True(s.IsDirty); + Assert.Equal( + new[] { "a.txt", "my file.txt", "old.txt → new.txt", "sub dir/æø.txt", "untracked.txt" }, + s.Changes.Select(c => c.Path)); + Assert.Equal(new[] { " M", " M", "R ", "A ", "??" }, s.Changes.Select(c => c.Code)); + } + + [Fact] + public void Tolerates_CRLF() + { + var s = GitService.ParseStatusV2(Captured.Replace("\n", "\r\n")); + Assert.Equal("master", s.Branch); + Assert.Equal(5, s.Changes.Count); + Assert.DoesNotContain(s.Changes, c => c.Path.EndsWith('\r')); + } + + [Fact] + public void Clean_repo_without_upstream() + { + var s = GitService.ParseStatusV2("# branch.oid abc\n# branch.head feature/x\n"); + Assert.Equal("feature/x", s.Branch); + Assert.Null(s.Upstream); + Assert.Null(s.Ahead); + Assert.Null(s.Behind); + Assert.False(s.IsDirty); + } + + [Fact] + public void Upstream_whose_remote_branch_is_gone_has_no_counts() + { + // git omits branch.ab in this case — seen in the capture above before the push landed. + var s = GitService.ParseStatusV2("# branch.oid abc\n# branch.head main\n# branch.upstream origin/main\n"); + Assert.Equal("origin/main", s.Upstream); + Assert.Null(s.Ahead); + } + + [Fact] + public void Detached_head_reports_no_branch() + { + var s = GitService.ParseStatusV2("# branch.oid abc\n# branch.head (detached)\n"); + Assert.True(s.IsDetached); + Assert.Null(s.Branch); + } + + [Fact] + public void Unmerged_entry_is_listed_as_conflict() + { + var s = GitService.ParseStatusV2( + "# branch.head main\n" + + "u UU N... 100644 100644 100644 100644 aaaa bbbb cccc conflicted file.txt\n"); + var c = Assert.Single(s.Changes); + Assert.Equal("UU", c.Code); + Assert.Equal("conflicted file.txt", c.Path); + } + + [Fact] + public void Unrecognised_lines_are_ignored_not_fatal() + { + var s = GitService.ParseStatusV2("# branch.head main\nwhat is this\n1 short\n# branch.ab garbage\n"); + Assert.Equal("main", s.Branch); + Assert.Empty(s.Changes); + Assert.Null(s.Ahead); + } +} + +public class GitStatusTextTests +{ + [Theory] + [InlineData(null, null, "")] + [InlineData(0, 0, "")] + [InlineData(2, 0, "↑2")] + [InlineData(0, 5, "↓5")] + [InlineData(2, 5, "↑2 ↓5")] + public void Sync(int? ahead, int? behind, string expected) => + Assert.Equal(expected, GitStatusText.Sync(ahead, behind)); + + [Fact] + public void MenuHeader_variants() + { + Assert.Equal("main → origin/main · ↑2 ↓5 · 3 changed", + GitStatusText.MenuHeader("main", "origin/main", 2, 5, 3)); + Assert.Equal("main → origin/main · up to date · clean", + GitStatusText.MenuHeader("main", "origin/main", 0, 0, 0)); + Assert.Equal("main → origin/main · upstream gone · 1 changed", + GitStatusText.MenuHeader("main", "origin/main", null, null, 1)); + Assert.Equal("wip · no upstream · clean", + GitStatusText.MenuHeader("wip", null, null, null, 0)); + Assert.Equal("detached HEAD · no upstream · clean", + GitStatusText.MenuHeader(null, null, null, null, 0)); + } + + [Fact] + public void FetchSummary_variants() + { + Assert.Equal("Fetched.", GitStatusText.FetchSummary("main", null, null, null)); + Assert.Equal("main is up to date with origin/main.", GitStatusText.FetchSummary("main", "origin/main", 0, 0)); + Assert.Equal("Nothing new — main is 2 ahead of origin/main.", GitStatusText.FetchSummary("main", "origin/main", 2, 0)); + Assert.Equal("1 new commit on origin/main — Pull to fast-forward.", GitStatusText.FetchSummary("main", "origin/main", 0, 1)); + Assert.Equal("4 new commits on origin/main; main has diverged (2 ahead).", GitStatusText.FetchSummary("main", "origin/main", 2, 4)); + } + + [Theory] + [InlineData("fatal: Not possible to fast-forward, aborting.", "diverged")] + [InlineData("error: Your local changes to the following files would be overwritten by merge:", "Commit or stash")] + [InlineData("fatal: could not read Username for 'https://github.com': terminal prompts disabled", "credentials")] + [InlineData("git@github.com: Permission denied (publickey).", "SSH")] + [InlineData("There is no tracking information for the current branch.", "no upstream")] + [InlineData("fatal: unable to access 'https://x/': Could not resolve host: x", "network")] + [InlineData("fatal: Unable to create '.git/index.lock': File exists.", "Another git command")] + public void FailureHint_recognises_common_errors(string output, string expectedFragment) => + Assert.Contains(expectedFragment, GitStatusText.FailureHint(output)); + + [Fact] + public void FailureHint_is_null_for_unknown_errors() => + Assert.Null(GitStatusText.FailureHint("fatal: something nobody anticipated")); + + [Theory] + [InlineData("Already up to date.", "Already up to date.")] + [InlineData("Updating 1a2b..3c4d\nFast-forward\n a.txt | 2 +-\n 3 files changed, 10 insertions(+), 2 deletions(-)", + "Fast-forwarded: 3 files changed, 10 insertions(+), 2 deletions(-)")] + [InlineData("Updating 1a2b..3c4d\nFast-forward", "Fast-forwarded.")] + public void SummarizePull(string output, string expected) => + Assert.Equal(expected, GitService.SummarizePull(output)); +} + +public class GitToolsArgvTests +{ + [Theory] + [InlineData("-x")] // a branch literally named like an option + [InlineData("--force")] + [InlineData("$(id)")] + [InlineData("a\"b")] + [InlineData("feature/with space")] + public void Switch_keeps_the_branch_one_argument_after_the_separator(string branch) + { + string cmd = GitService.BuildLocalGitCommandLine(@"C:\repo", GitService.SwitchArgs(branch)); + string[] argv = Win32CommandLineTests.Split("git " + cmd); + Assert.Equal(new[] { "git", "-C", @"C:\repo", "switch", "--no-guess", "--", branch }, argv); + } + + [Fact] + public void WslEnv_gains_the_git_variables_once_and_keeps_existing_entries() + { + Assert.Equal("GIT_TERMINAL_PROMPT:GIT_OPTIONAL_LOCKS", + GitService.MergeWslEnv(null, new[] { "GIT_TERMINAL_PROMPT", "GIT_OPTIONAL_LOCKS" })); + Assert.Equal("USERPROFILE/p:GIT_TERMINAL_PROMPT", + GitService.MergeWslEnv("USERPROFILE/p", new[] { "GIT_TERMINAL_PROMPT" })); + Assert.Equal("GIT_TERMINAL_PROMPT/u", + GitService.MergeWslEnv("GIT_TERMINAL_PROMPT/u", new[] { "GIT_TERMINAL_PROMPT" })); + } + + [Fact] + public void Every_git_never_prompts_and_reads_skip_optional_locks() + { + Assert.Contains(GitService.GitEnvironment(readOnly: false), kv => kv.Key == "GIT_TERMINAL_PROMPT" && kv.Value == "0"); + Assert.DoesNotContain(GitService.GitEnvironment(readOnly: false), kv => kv.Key == "GIT_OPTIONAL_LOCKS"); + Assert.Contains(GitService.GitEnvironment(readOnly: true), kv => kv.Key == "GIT_OPTIONAL_LOCKS" && kv.Value == "0"); + } +} + +/// +/// The operations end to end, against a bare repo standing in for the remote and two clones: +/// "upstream" pushes, "work" is what a session would be sitting in. +/// +public sealed class GitToolsRepoTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "csm-gittools-" + Guid.NewGuid().ToString("N")[..8]); + private string Remote => Path.Combine(_root, "remote.git"); + private string Upstream => Path.Combine(_root, "upstream"); + private string Work => Path.Combine(_root, "work"); + + public GitToolsRepoTests() + { + Directory.CreateDirectory(_root); + Git(_root, "init", "-q", "--bare", "-b", "main", Remote); + Git(_root, "clone", "-q", Remote, Upstream); + Configure(Upstream); + File.WriteAllText(Path.Combine(Upstream, "a.txt"), "one\n"); + Git(Upstream, "add", "a.txt"); + Git(Upstream, "commit", "-q", "-m", "one"); + Git(Upstream, "push", "-q", "origin", "HEAD:main"); + Git(_root, "clone", "-q", Remote, Work); + Configure(Work); + } + + private static void Configure(string repo) + { + Git(repo, "config", "user.email", "test@example.invalid"); + Git(repo, "config", "user.name", "test"); + Git(repo, "config", "commit.gpgsign", "false"); + Git(repo, "config", "core.autocrlf", "false"); + } + + private void PushUpstreamCommit(string file, string content) + { + File.WriteAllText(Path.Combine(Upstream, file), content); + Git(Upstream, "add", file); + Git(Upstream, "commit", "-q", "-m", file); + Git(Upstream, "push", "-q", "origin", "HEAD:main"); + } + + private static string Git(string cwd, params string[] args) + { + var psi = new ProcessStartInfo("git") + { + WorkingDirectory = cwd, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + // Pinned so a machine-wide core.autocrlf=true can't make a fresh clone look modified. + psi.ArgumentList.Add("-c"); + psi.ArgumentList.Add("core.autocrlf=false"); + foreach (var a in args) psi.ArgumentList.Add(a); + using var p = Process.Start(psi)!; + string o = p.StandardOutput.ReadToEnd(); + string e = p.StandardError.ReadToEnd(); + p.WaitForExit(); + if (p.ExitCode != 0) throw new InvalidOperationException($"git {string.Join(' ', args)}: {e}"); + return o; + } + + [Fact] + public async Task Status_reports_branch_upstream_and_counts() + { + File.WriteAllText(Path.Combine(Work, "new.txt"), "x"); + var s = await GitService.GetStatusAsync(Work); + Assert.NotNull(s); + Assert.Equal("main", s!.Branch); + Assert.Equal("origin/main", s.Upstream); + Assert.Equal(0, s.Ahead); + Assert.Equal(0, s.Behind); + Assert.Equal("??", Assert.Single(s.Changes).Code); + + var (branch, dirty) = await GitService.GetGitInfoAsync(Work); + Assert.Equal("main", branch); + Assert.True(dirty); + } + + [Fact] + public async Task Status_of_a_plain_folder_is_null() + { + string plain = Path.Combine(_root, "plain"); + Directory.CreateDirectory(plain); + // A bare folder under %TEMP% might still sit inside some enclosing repo on a dev box; + // GIT_CEILING_DIRECTORIES would fix that but isn't ours to set. Assert only when git + // agrees it's not a repo. + var s = await GitService.GetStatusAsync(plain); + if (s is not null) return; + Assert.Equal((null, false), await GitService.GetGitInfoAsync(plain)); + } + + [Fact] + public async Task Fetch_then_pull_fast_forwards() + { + PushUpstreamCommit("b.txt", "two\n"); + + var fetch = await GitService.FetchAsync(Work); + Assert.True(fetch.Ok, fetch.Output); + var s = await GitService.GetStatusAsync(Work); + Assert.Equal(1, s!.Behind); + + var pull = await GitService.PullFastForwardAsync(Work); + Assert.True(pull.Ok, pull.Output); + Assert.StartsWith("Fast-forwarded", GitService.SummarizePull(pull.Output)); + Assert.True(File.Exists(Path.Combine(Work, "b.txt"))); + Assert.Equal(0, (await GitService.GetStatusAsync(Work))!.Behind); + + var again = await GitService.PullFastForwardAsync(Work); + Assert.True(again.Ok, again.Output); + Assert.Equal("Already up to date.", GitService.SummarizePull(again.Output)); + } + + [Fact] + public async Task Pull_refuses_to_merge_a_diverged_branch() + { + PushUpstreamCommit("b.txt", "theirs\n"); + File.WriteAllText(Path.Combine(Work, "c.txt"), "mine\n"); + Git(Work, "add", "c.txt"); + Git(Work, "commit", "-q", "-m", "mine"); + // A user's pull.rebase=true must not turn this into a rebase. + Git(Work, "config", "pull.rebase", "true"); + string headBefore = Git(Work, "rev-parse", "HEAD").Trim(); + + var pull = await GitService.PullFastForwardAsync(Work); + + Assert.False(pull.Ok); + Assert.Contains("diverged", GitStatusText.FailureHint(pull.Output)); + Assert.Equal(headBefore, Git(Work, "rev-parse", "HEAD").Trim()); + } + + [Fact] + public async Task Pull_refuses_to_overwrite_local_edits() + { + PushUpstreamCommit("a.txt", "theirs\n"); + File.WriteAllText(Path.Combine(Work, "a.txt"), "my uncommitted edit\n"); + + var pull = await GitService.PullFastForwardAsync(Work); + + Assert.False(pull.Ok); + Assert.Contains("Commit or stash", GitStatusText.FailureHint(pull.Output)); + Assert.Equal("my uncommitted edit\n", File.ReadAllText(Path.Combine(Work, "a.txt"))); + } + + [Fact] + public async Task Switch_handles_a_branch_named_like_an_option() + { + Git(Work, "branch", "feature"); + Git(Work, "update-ref", "refs/heads/-x", "HEAD"); + + var r1 = await GitService.SwitchBranchAsync(Work, "feature"); + Assert.True(r1.Ok, r1.Output); + Assert.Equal("feature", Git(Work, "branch", "--show-current").Trim()); + + var r2 = await GitService.SwitchBranchAsync(Work, "-x"); + Assert.True(r2.Ok, r2.Output); + Assert.Equal("-x", Git(Work, "branch", "--show-current").Trim()); + } + + [Fact] + public async Task Switch_does_not_invent_a_branch_that_exists_only_on_the_remote() + { + Git(Upstream, "push", "-q", "origin", "HEAD:remote-only"); + Git(Work, "fetch", "-q"); + + var r = await GitService.SwitchBranchAsync(Work, "remote-only"); + + Assert.False(r.Ok); + Assert.Equal("main", Git(Work, "branch", "--show-current").Trim()); + } + + [Fact] + public async Task Fetch_from_an_unreachable_https_remote_fails_fast_instead_of_prompting() + { + // Port 9 (discard) on localhost: refused at once. Without GIT_TERMINAL_PROMPT=0 the + // interesting case is a remote that asks for credentials; this at least proves a + // failing fetch returns promptly with git's message rather than hanging to the timeout. + Git(Work, "remote", "set-url", "origin", "https://127.0.0.1:9/nope.git"); + var sw = Stopwatch.StartNew(); + + var r = await GitService.FetchAsync(Work); + + Assert.False(r.Ok); + Assert.False(r.TimedOut); + Assert.False(string.IsNullOrWhiteSpace(r.Output)); + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(30), $"took {sw.Elapsed}"); + } + + [Fact] + public async Task StatusText_is_git_status_prose() + { + var r = await GitService.StatusTextAsync(Work); + Assert.True(r.Ok, r.Output); + Assert.Contains("On branch main", r.Output); + } + + public void Dispose() + { + try + { + // git marks pack files read-only; clear that or Delete throws. + foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories)) + File.SetAttributes(f, FileAttributes.Normal); + Directory.Delete(_root, recursive: true); + } + catch { } + } +}