fix(ui): warn and block save when a list's working dir isn't a git repo

Reuses RepoScanner.IsRepo (now public) to validate WorkingDir whenever it
changes in ListSettingsModalViewModel — Browse or typing. A missing folder
or a non-repo folder shows an inline warning and gates Save via the same
CanExecute pattern the load-failure gate already uses; an empty WorkingDir
is untouched. Previously this only surfaced as a Failed task deep in
WorktreeManager on first run.

Also localizes the two hardcoded EN strings in the same file (delete
confirm, planning-sessions block) into vm.listSettings.* keys.
This commit is contained in:
mika kuns
2026-09-10 13:32:52 +02:00
parent 752cf0926b
commit 3348736e9d
6 changed files with 69 additions and 7 deletions
+1 -1
View File
@@ -805,7 +805,7 @@
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, "sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen darin bleiben erhalten und wandern auf die oberste Ebene." }, "listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen darin bleiben erhalten und wandern auf die oberste Ebene.", "confirmDeleteList": "Liste \"{0}\" und alle ihre Aufgaben löschen? Das kann nicht rückgängig gemacht werden.", "deleteBlockedPlanning": "Diese Liste hat Planungssitzungen mit Kind-Aufgaben. Erst diese verwerfen, dann die Liste löschen.", "workingDirMissing": "Dieser Ordner existiert nicht.", "workingDirNotRepo": "Dieser Ordner ist noch kein Git-Repository. Führe darin zuerst `git init` aus, oder leere das Arbeitsverzeichnis, um dies als manuelle Liste zu speichern." },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" }, "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, "mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
+1 -1
View File
@@ -805,7 +805,7 @@
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" }, "sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" },
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists inside it are kept and move back to the top level." }, "listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists inside it are kept and move back to the top level.", "confirmDeleteList": "Delete list \"{0}\" and all its tasks? This cannot be undone.", "deleteBlockedPlanning": "This list has planning sessions with child tasks. Discard those first, then delete the list.", "workingDirMissing": "This folder doesn't exist.", "workingDirNotRepo": "This folder isn't a git repository yet. Run `git init` in it first, or clear the working directory to save this as a manual list." },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" }, "detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" }, "mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
"about": { "openPathFailed": "Couldn't open path: {0}" }, "about": { "openPathFailed": "Couldn't open path: {0}" },
+1 -1
View File
@@ -69,5 +69,5 @@ public static class RepoScanner
// A real clone has .git as a directory. Worktrees and submodules have it as a file // A real clone has .git as a directory. Worktrees and submodules have it as a file
// ("gitdir: ...") — those are not standalone repos and would flood the list. // ("gitdir: ...") — those are not standalone repos and would flood the list.
private static bool IsRepo(string dir) => Directory.Exists(Path.Combine(dir, ".git")); public static bool IsRepo(string dir) => Directory.Exists(Path.Combine(dir, ".git"));
} }
@@ -39,6 +39,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
[ObservableProperty] private string _name = ""; [ObservableProperty] private string _name = "";
[ObservableProperty] private string _workingDir = ""; [ObservableProperty] private string _workingDir = "";
// Recomputed whenever WorkingDir changes (typing or Browse) — RepoScanner.IsRepo is the same
// check RepoImport uses, so a folder either scans in there or warns here, never neither.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string _workingDirWarning = "";
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType; [ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
// A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual). // A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual).
[ObservableProperty] private bool _isManual; [ObservableProperty] private bool _isManual;
@@ -250,9 +255,20 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
} }
} }
private bool CanSave() => !IsLoading && !_loadFailed; private bool CanSave() => !IsLoading && !_loadFailed && (IsFolder || string.IsNullOrEmpty(WorkingDirWarning));
private bool CanDelete() => !IsLoading; private bool CanDelete() => !IsLoading;
// A manual list without a WorkingDir is untouched (empty is always valid) — this only fires
// for the "picked a folder that turns out not to be a repo" case the audit found: it would
// otherwise only surface as a Failed task the first time the worker tries to use it.
partial void OnWorkingDirChanged(string value)
{
if (string.IsNullOrWhiteSpace(value)) { WorkingDirWarning = ""; return; }
WorkingDirWarning = !Directory.Exists(value)
? Loc.T("vm.listSettings.workingDirMissing")
: RepoScanner.IsRepo(value) ? "" : Loc.T("vm.listSettings.workingDirNotRepo");
}
[RelayCommand(CanExecute = nameof(CanSave))] [RelayCommand(CanExecute = nameof(CanSave))]
private async Task SaveAsync() private async Task SaveAsync()
{ {
@@ -330,7 +346,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name; var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
if (ConfirmAsync is not null) if (ConfirmAsync is not null)
{ {
var ok = await ConfirmAsync($"Delete list \"{displayName}\" and all its tasks? This cannot be undone."); var ok = await ConfirmAsync(Loc.T("vm.listSettings.confirmDeleteList", displayName));
if (!ok) return; if (!ok) return;
} }
@@ -347,7 +363,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true)) || ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true))
{ {
if (ShowErrorAsync is not null) if (ShowErrorAsync is not null)
await ShowErrorAsync("This list has planning sessions with child tasks. Discard those first, then delete the list."); await ShowErrorAsync(Loc.T("vm.listSettings.deleteBlockedPlanning"));
return; return;
} }
@@ -94,6 +94,9 @@
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" PlaceholderText="{loc:Tr modals.listSettings.workingDirectoryPlaceholder}" /> <TextBox Grid.Column="0" Text="{Binding WorkingDir}" PlaceholderText="{loc:Tr modals.listSettings.workingDirectoryPlaceholder}" />
<Button Classes="btn" Grid.Column="1" Content="{loc:Tr modals.listSettings.browse}" Margin="8,0,0,0" Click="BrowseClicked" /> <Button Classes="btn" Grid.Column="1" Content="{loc:Tr modals.listSettings.browse}" Margin="8,0,0,0" Click="BrowseClicked" />
</Grid> </Grid>
<TextBlock Text="{Binding WorkingDirWarning}"
IsVisible="{Binding WorkingDirWarning, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Foreground="{DynamicResource StatusErrorBrush}" FontSize="12" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}"> <StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
@@ -161,7 +161,9 @@ public class ListSettingsModalViewModelTests
string? reported = null; string? reported = null;
vm.ErrorReported += m => reported = m; vm.ErrorReported += m => reported = m;
await vm.LoadAsync("l1", "List One", "/repo", "feat"); // Empty working dir: this test is about ticket-project load isolation, not the
// working-dir-is-a-repo gate added for the "not a git repo" Save check.
await vm.LoadAsync("l1", "List One", "", "feat");
Assert.Equal("make test", vm.VerifyCommand); Assert.Equal("make test", vm.VerifyCommand);
Assert.True(vm.PowerUseAllowed); Assert.True(vm.PowerUseAllowed);
@@ -169,4 +171,45 @@ public class ListSettingsModalViewModelTests
Assert.NotNull(reported); Assert.NotNull(reported);
Assert.Contains("ticket system down", reported); Assert.Contains("ticket system down", reported);
} }
[Fact]
public async Task LoadAsync_WorkingDirNotAGitRepo_WarnsAndBlocksSave()
{
var plainDir = Directory.CreateTempSubdirectory().FullName;
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", plainDir, "feat");
Assert.NotEmpty(vm.WorkingDirWarning);
Assert.False(vm.SaveCommand.CanExecute(null));
}
[Fact]
public async Task LoadAsync_WorkingDirIsAGitRepo_NoWarningAndSaveAllowed()
{
var repoDir = Directory.CreateTempSubdirectory().FullName;
Directory.CreateDirectory(Path.Combine(repoDir, ".git"));
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", repoDir, "feat");
Assert.Empty(vm.WorkingDirWarning);
Assert.True(vm.SaveCommand.CanExecute(null));
}
[Fact]
public void WorkingDir_ClearedAfterNotARepoWarning_ClearsWarningAndAllowsSave()
{
var plainDir = Directory.CreateTempSubdirectory().FullName;
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
var vm = new ListSettingsModalViewModel(worker, NewFactory()) { WorkingDir = plainDir };
Assert.NotEmpty(vm.WorkingDirWarning);
vm.WorkingDir = "";
Assert.Empty(vm.WorkingDirWarning);
Assert.True(vm.SaveCommand.CanExecute(null));
}
} }