diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 63ae3f1c..409c614f 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -1503,12 +1503,17 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable catch { /* best-effort */ } } + // Shared by TaskRowViewModel.UpdateFromEntity — this is the one parser for + // `git diff --stat`'s summary line ("N insertions(+), M deletions(-)"). Anchored on the + // literal "(+)"/"(-)" markers rather than the English words: git for Windows localizes + // "insertion"/"deletion" to the OS language (e.g. German "Einfügung(en)"/"Löschung(en)"), + // but always keeps the untranslated (+)/(-) suffix, so this reads correctly in any locale. internal static (int Additions, int Deletions) ParseDiffStat(string? stat) { if (string.IsNullOrEmpty(stat)) return (0, 0); int add = 0, del = 0; - var m1 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+insertion"); - var m2 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+deletion"); + var m1 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)[^,\n]*\(\+\)"); + var m2 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)[^,\n]*\(-\)"); if (m1.Success) int.TryParse(m1.Groups[1].Value, out add); if (m2.Success) int.TryParse(m2.Groups[1].Value, out del); return (add, del); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index dbbdb5c8..e47b1ded 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -500,7 +500,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase // Any entity-backed refresh reflects a settled state, so the transient creation-phase // banner (set from the OperationProgress broadcast) has nothing left to announce. CreationPhase = null; - var (add, del) = ParseDiffStat(t.Worktree?.DiffStat); + var (add, del) = DetailsIslandViewModel.ParseDiffStat(t.Worktree?.DiffStat); Number = t.Number; Title = t.Title; ListName = t.List?.Name ?? ""; @@ -529,18 +529,4 @@ public sealed partial class TaskRowViewModel : ViewModelBase FailureTurnsUsed = t.FailureTurnsUsed; FailureMaxTurns = t.FailureMaxTurns; } - - // Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions". - private static (int add, int del) ParseDiffStat(string? s) - { - if (string.IsNullOrWhiteSpace(s)) return (0, 0); - int add = 0, del = 0; - var parts = s.Split(new[] { ' ', ',', '\t' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var p in parts) - { - if (p.Length > 1 && p[0] == '+' && int.TryParse(p.AsSpan(1), out var a)) add = a; - else if (p.Length > 1 && (p[0] == '-' || p[0] == '\u2212') && int.TryParse(p.AsSpan(1), out var d)) del = d; - } - return (add, del); - } } diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 6e4dfc48..c46c087a 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -130,7 +130,7 @@ Full flow, invariants, and model/effort/max-turns resolution (including the low- - **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)* - **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume` - **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser. -- **WorktreeManager** — worktrees on `claudedo/{taskId[:8]}` branches; commits with semantic messages, updates DB with head commit + diff stats +- **WorktreeManager** — worktrees on `claudedo/{id}` branches (full task GUID, dashes stripped — a prefix isn't enough to rule out collisions); commits with semantic messages, updates DB with head commit + diff stats - **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal. - **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history - **AgentFileService** — manages `~/.claudeDo/agents/*.md`; list/refresh via SignalR diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/ParseDiffStatTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/ParseDiffStatTests.cs index b41a4bf0..a4fa8210 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/ParseDiffStatTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/ParseDiffStatTests.cs @@ -1,4 +1,6 @@ +using ClaudeDo.Data.Models; using ClaudeDo.Ui.ViewModels.Islands; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.UiVm; @@ -35,4 +37,59 @@ public class ParseDiffStatTests Assert.Equal(5, add); Assert.Equal(0, del); } + + [Fact] + public void Deletions_Only_Returns_Zero_Insertions() + { + var (add, del) = DetailsIslandViewModel.ParseDiffStat("1 file changed, 4 deletions(-)"); + Assert.Equal(0, add); + Assert.Equal(4, del); + } + + // German-Windows git localizes "insertion(s)"/"deletion(s)" (e.g. "Einfügung(en)"/ + // "Löschung(en)") but keeps the (+)/(-) suffix untranslated — the regex anchors on that. + [Fact] + public void German_Locale_Stat_Parses_Both() + { + var (add, del) = DetailsIslandViewModel.ParseDiffStat("2 Dateien geändert, 10 Einfügungen(+), 3 Löschungen(-)"); + Assert.Equal(10, add); + Assert.Equal(3, del); + } + + [Fact] + public void German_Locale_Insertions_Only_Returns_Zero_Deletions() + { + var (add, del) = DetailsIslandViewModel.ParseDiffStat("1 Datei geändert, 5 Einfügung(en)(+)"); + Assert.Equal(5, add); + Assert.Equal(0, del); + } + + // TaskRowViewModel used to run its own broken parser (expecting "+12"/"-3" tokens that + // `git diff --stat` never emits) — pin it to the same shared, correct parser as the detail badge. + [Fact] + public void TaskRowViewModel_UpdateFromEntity_Parses_Real_DiffStat_Format() + { + var task = new TaskEntity + { + Id = "t1", + ListId = "l1", + Title = "task", + CreatedAt = DateTime.UtcNow, + Status = TaskStatus.Done, + Worktree = new WorktreeEntity + { + TaskId = "t1", + Path = "/tmp/wt", + BranchName = "claudedo/t1", + BaseCommit = "abc", + DiffStat = "2 files changed, 10 insertions(+), 3 deletions(-)", + CreatedAt = DateTime.UtcNow, + }, + }; + + var row = TaskRowViewModel.FromEntity(task); + + Assert.Equal(10, row.DiffAdditions); + Assert.Equal(3, row.DiffDeletions); + } }