Merge feat/repo-import-list-helper: add repos-as-lists import helper

This commit is contained in:
mika kuns
2026-05-29 15:59:45 +02:00
15 changed files with 460 additions and 14 deletions

View File

@@ -104,6 +104,8 @@ sealed class Program
sc.AddTransient<MergeModalViewModel>(); sc.AddTransient<MergeModalViewModel>();
sc.AddTransient<Func<MergeModalViewModel>>(sp => () => sp.GetRequiredService<MergeModalViewModel>()); sc.AddTransient<Func<MergeModalViewModel>>(sp => () => sp.GetRequiredService<MergeModalViewModel>());
sc.AddTransient<ListSettingsModalViewModel>(); sc.AddTransient<ListSettingsModalViewModel>();
sc.AddTransient<RepoImportModalViewModel>();
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
// Islands shell VMs // Islands shell VMs
sc.AddSingleton<ListsIslandViewModel>(sp => sc.AddSingleton<ListsIslandViewModel>(sp =>

View File

@@ -18,6 +18,7 @@ MVVM with CommunityToolkit.Mvvm source generators:
- **ListEditorView** — Modal dialog for list create/edit - **ListEditorView** — Modal dialog for list create/edit
- **StatusBarView** — Connection status indicator, active task display - **StatusBarView** — Connection status indicator, active task display
- **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath. Opened via context menu or gear button on a list row. - **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath. Opened via context menu or gear button on a list row.
- **RepoImportModalView** — bulk-creates lists from git repos discovered under chosen parent folders. Opened via the folder button beside "New list" in the Lists island, or the "Add repos as lists…" Help-menu item. Repos already wired to a list show as disabled/"(already added)".
- **DetailsIslandView** — contains an "Agent settings (overrides)" expander with per-task Model/SystemPrompt/AgentPath, showing inherited effective values. Disabled while task is running. - **DetailsIslandView** — contains an "Agent settings (overrides)" expander with per-task Model/SystemPrompt/AgentPath, showing inherited effective values. Disabled while task is running.
All views use compiled bindings (`x:DataType`). All views use compiled bindings (`x:DataType`).

View File

@@ -0,0 +1,26 @@
namespace ClaudeDo.Ui.Services;
public sealed record RepoCandidate(string Name, string FullPath);
public static class RepoScanner
{
public static IReadOnlyList<RepoCandidate> Scan(string parentFolder)
{
if (string.IsNullOrWhiteSpace(parentFolder) || !Directory.Exists(parentFolder))
return Array.Empty<RepoCandidate>();
var result = new List<RepoCandidate>();
IEnumerable<string> subdirs;
try { subdirs = Directory.EnumerateDirectories(parentFolder); }
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{ return Array.Empty<RepoCandidate>(); }
foreach (var dir in subdirs)
{
var gitPath = Path.Combine(dir, ".git");
if (Directory.Exists(gitPath) || File.Exists(gitPath))
result.Add(new RepoCandidate(Path.GetFileName(dir), dir));
}
return result;
}
}

View File

@@ -29,6 +29,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; } public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; } public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; } public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
public Func<RepoImportModalViewModel, System.Threading.Tasks.Task>? ShowRepoImportModal { get; set; }
[RelayCommand] [RelayCommand]
private async Task OpenSettings() private async Task OpenSettings()
@@ -50,6 +51,16 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
await RefreshRowAsync(row.Id); await RefreshRowAsync(row.Id);
} }
[RelayCommand]
private async System.Threading.Tasks.Task OpenRepoImportAsync()
{
if (ShowRepoImportModal is null || _services is null) return;
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
await vm.LoadAsync();
await ShowRepoImportModal(vm);
await LoadAsync();
}
private bool _worktreesOverviewOpen; private bool _worktreesOverviewOpen;
[RelayCommand] [RelayCommand]

View File

@@ -34,6 +34,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!; private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!; private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory; public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
@@ -43,6 +44,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
// Set by MainWindow to open the About dialog. // Set by MainWindow to open the About dialog.
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; } public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
// Set by MainWindow to open the repo-import dialog.
public Func<RepoImportModalViewModel, Task>? ShowRepoImportModal { get; set; }
// Set by MainWindow to open the global worktrees overview dialog. // Set by MainWindow to open the global worktrees overview dialog.
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; } public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
@@ -168,7 +172,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
InstallerLocator installerLocator, InstallerLocator installerLocator,
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory, Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
Func<MergeModalViewModel> mergeVmFactory) Func<MergeModalViewModel> mergeVmFactory,
Func<RepoImportModalViewModel> repoImportVmFactory)
{ {
Lists = lists; Tasks = tasks; Details = details; Worker = worker; Lists = lists; Tasks = tasks; Details = details; Worker = worker;
_updateCheck = updateCheck; _updateCheck = updateCheck;
@@ -176,6 +181,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worktreesOverviewVmFactory = worktreesOverviewVmFactory; _worktreesOverviewVmFactory = worktreesOverviewVmFactory;
_mergeVmFactory = mergeVmFactory; _mergeVmFactory = mergeVmFactory;
_repoImportVmFactory = repoImportVmFactory;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync(); Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
@@ -260,6 +266,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
if (ShowAboutModal is not null) await ShowAboutModal(vm); if (ShowAboutModal is not null) await ShowAboutModal(vm);
} }
[RelayCommand]
private async Task OpenRepoImport()
{
if (ShowRepoImportModal is null || _repoImportVmFactory is null) return;
var vm = _repoImportVmFactory();
await vm.LoadAsync();
await ShowRepoImportModal(vm);
if (Lists is not null) await Lists.LoadAsync();
}
private bool _worktreesOverviewOpen; private bool _worktreesOverviewOpen;
[RelayCommand] [RelayCommand]

View File

@@ -0,0 +1,15 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class RepoImportItemViewModel : ViewModelBase
{
public string Name { get; init; } = "";
public string FullPath { get; init; } = "";
// True when a list already points at this path. Such rows are shown ticked + disabled.
public bool AlreadyAdded { get; init; }
public bool CanToggle => !AlreadyAdded;
[ObservableProperty] private bool _isChecked;
}

View File

@@ -0,0 +1,121 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class RepoImportModalViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
public ObservableCollection<RepoImportItemViewModel> Repos { get; } = new();
public Action? CloseAction { get; set; }
public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded);
public bool CanCreate => CreateCount > 0;
public string CreateButtonText => $"Create {CreateCount} list(s)";
public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task LoadAsync(CancellationToken ct = default)
{
Repos.Clear();
_existingDirs.Clear();
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
foreach (var l in await lists.GetAllAsync(ct))
{
if (!string.IsNullOrWhiteSpace(l.WorkingDir))
_existingDirs.Add(l.WorkingDir!);
}
NotifyCreateState();
}
public void AddFolders(IEnumerable<string> folders)
{
var current = new HashSet<string>(
Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase);
foreach (var folder in folders)
{
var found = RepoScanner.Scan(folder);
foreach (var item in BuildCandidates(found, current, _existingDirs))
{
item.PropertyChanged += OnItemChanged;
Repos.Add(item);
current.Add(item.FullPath);
}
}
NotifyCreateState();
}
public static List<RepoImportItemViewModel> BuildCandidates(
IEnumerable<RepoCandidate> found,
IReadOnlySet<string> currentPaths,
IReadOnlySet<string> existingDirs)
{
var items = new List<RepoImportItemViewModel>();
foreach (var c in found)
{
if (currentPaths.Contains(c.FullPath)) continue;
items.Add(new RepoImportItemViewModel
{
Name = c.Name,
FullPath = c.FullPath,
AlreadyAdded = existingDirs.Contains(c.FullPath),
IsChecked = true,
});
}
return items;
}
private void OnItemChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(RepoImportItemViewModel.IsChecked))
NotifyCreateState();
}
private void NotifyCreateState()
{
OnPropertyChanged(nameof(CreateCount));
OnPropertyChanged(nameof(CanCreate));
OnPropertyChanged(nameof(CreateButtonText));
}
[RelayCommand]
private async Task CreateAsync()
{
var toCreate = Repos.Where(r => r.IsChecked && !r.AlreadyAdded).ToList();
if (toCreate.Count > 0)
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
foreach (var r in toCreate)
{
await lists.AddAsync(new ListEntity
{
Id = Guid.NewGuid().ToString("N"),
Name = r.Name,
WorkingDir = r.FullPath,
DefaultCommitType = CommitTypeRegistry.DefaultType,
CreatedAt = DateTime.UtcNow,
});
}
}
CloseAction?.Invoke();
}
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();
}

View File

@@ -168,8 +168,9 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<!-- + New list button --> <!-- New list + import row -->
<Button Classes="new-list-btn" Margin="0,4,0,0" <Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="new-list-btn"
Command="{Binding CreateListCommand}"> Command="{Binding CreateListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="{StaticResource Icon.Plus}" <PathIcon Data="{StaticResource Icon.Plus}"
@@ -181,6 +182,14 @@
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
</StackPanel> </StackPanel>
</Button> </Button>
<Button Grid.Column="1" Classes="icon-btn" Margin="6,0,0,0"
Command="{Binding OpenRepoImportCommand}"
ToolTip.Tip="Add repos as lists">
<PathIcon Data="{StaticResource Icon.Folder}"
Width="14" Height="14"
Foreground="{DynamicResource TextMuteBrush}"/>
</Button>
</Grid>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>

View File

@@ -30,6 +30,14 @@ public partial class ListsIslandView : UserControl
if (top is null) window.Show(); if (top is null) window.Show();
else await window.ShowDialog(top); else await window.ShowDialog(top);
}; };
vm.ShowRepoImportModal = async modal =>
{
var window = new RepoImportModalView { DataContext = modal };
modal.CloseAction = () => window.Close();
var top = TopLevel.GetTopLevel(this) as Window;
if (top is null) window.Show();
else await window.ShowDialog(top);
};
vm.ShowWorktreesOverviewModal = async modal => vm.ShowWorktreesOverviewModal = async modal =>
{ {
var top = TopLevel.GetTopLevel(this) as Window; var top = TopLevel.GetTopLevel(this) as Window;

View File

@@ -72,6 +72,7 @@
<MenuItem Header="Worktrees…" <MenuItem Header="Worktrees…"
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/> Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/> <MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>
<MenuItem Header="Add repos as lists…" Command="{Binding OpenRepoImportCommand}"/>
</MenuItem> </MenuItem>
</Menu> </Menu>
</StackPanel> </StackPanel>

View File

@@ -62,6 +62,12 @@ public partial class MainWindow : Window
}; };
await dlg.ShowDialog(this); await dlg.ShowDialog(this);
}; };
vm.ShowRepoImportModal = async (modal) =>
{
var dlg = new RepoImportModalView { DataContext = modal };
modal.CloseAction = () => dlg.Close();
await dlg.ShowDialog(this);
};
} }
} }

View File

@@ -0,0 +1,73 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.RepoImportModalView"
x:DataType="vm:RepoImportModalViewModel"
Title="Add repos as lists"
Width="560" Height="480"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
</Window.KeyBindings>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1">
<Grid RowDefinitions="36,Auto,*,52">
<!-- Header -->
<Border Grid.Row="0" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="ADD REPOS AS LISTS" FontFamily="{DynamicResource MonoFont}" FontSize="11"
LetterSpacing="1.4" Foreground="{DynamicResource TextBrush}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn" Content="✕" FontSize="12"
Command="{Binding CancelCommand}" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Add folder row -->
<Border Grid.Row="1" Padding="16,12,16,4">
<Button Content="Add folder…" Click="AddFolderClicked" HorizontalAlignment="Left"/>
</Border>
<!-- Repo checklist -->
<ScrollViewer Grid.Row="2" Padding="16,4,16,8">
<ItemsControl ItemsSource="{Binding Repos}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:RepoImportItemViewModel">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,4">
<CheckBox Grid.Column="0"
IsChecked="{Binding IsChecked, Mode=TwoWay}"
IsEnabled="{Binding CanToggle}"
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Margin="6,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource TextBrush}" FontSize="13"/>
<TextBlock Text="{Binding FullPath}" Foreground="{DynamicResource TextFaintBrush}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="(already added)"
Foreground="{DynamicResource TextFaintBrush}" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding AlreadyAdded}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="3" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right"
VerticalAlignment="Center" Margin="16,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="{Binding CreateButtonText}" Command="{Binding CreateCommand}"
IsEnabled="{Binding CanCreate}" MinWidth="120" Classes="primary"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,30 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class RepoImportModalView : Window
{
public RepoImportModalView()
{
InitializeComponent();
}
private async void AddFolderClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not RepoImportModalViewModel vm) return;
var top = TopLevel.GetTopLevel(this);
if (top is null) return;
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Choose folders containing repos",
AllowMultiple = true,
});
if (folders.Count == 0) return;
vm.AddFolders(folders.Select(f => f.Path.LocalPath));
}
}

View File

@@ -0,0 +1,49 @@
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests;
public sealed class RepoImportCandidatesTests
{
[Fact]
public void BuildCandidates_NewRepo_IsCheckedAndNotAlreadyAdded()
{
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Single(items);
Assert.True(items[0].IsChecked);
Assert.False(items[0].AlreadyAdded);
Assert.Equal("repo-a", items[0].Name);
Assert.Equal(@"C:\src\repo-a", items[0].FullPath);
}
[Fact]
public void BuildCandidates_ExistingWorkingDir_IsMarkedAlreadyAdded()
{
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase);
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Single(items);
Assert.True(items[0].AlreadyAdded);
Assert.True(items[0].IsChecked); // already-added rows render ticked
}
[Fact]
public void BuildCandidates_SkipsPathsAlreadyShown()
{
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
var current = new HashSet<string>(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Empty(items);
}
}

View File

@@ -0,0 +1,78 @@
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.Tests;
public sealed class RepoScannerTests : IDisposable
{
private readonly string _root =
Path.Combine(Path.GetTempPath(), "repo-scan-" + Guid.NewGuid().ToString("N"));
public RepoScannerTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch { }
}
private string MakeDir(string name)
{
var p = Path.Combine(_root, name);
Directory.CreateDirectory(p);
return p;
}
[Fact]
public void Scan_ReturnsSubfoldersWithGitDirectory()
{
var repo = MakeDir("repo-a");
Directory.CreateDirectory(Path.Combine(repo, ".git"));
var result = RepoScanner.Scan(_root);
Assert.Single(result);
Assert.Equal("repo-a", result[0].Name);
Assert.Equal(repo, result[0].FullPath);
}
[Fact]
public void Scan_TreatsDotGitFileAsRepo()
{
var repo = MakeDir("worktree-repo");
File.WriteAllText(Path.Combine(repo, ".git"), "gitdir: ../somewhere");
var result = RepoScanner.Scan(_root);
Assert.Single(result);
Assert.Equal("worktree-repo", result[0].Name);
}
[Fact]
public void Scan_IgnoresPlainFolders()
{
MakeDir("not-a-repo");
var result = RepoScanner.Scan(_root);
Assert.Empty(result);
}
[Fact]
public void Scan_IsNotRecursive()
{
var nested = MakeDir(Path.Combine("outer", "inner"));
Directory.CreateDirectory(Path.Combine(nested, ".git"));
// outer itself has no .git
var result = RepoScanner.Scan(_root);
Assert.Empty(result);
}
[Fact]
public void Scan_ReturnsEmptyForMissingFolder()
{
var result = RepoScanner.Scan(Path.Combine(_root, "does-not-exist"));
Assert.Empty(result);
}
}