From 62a1121571472f25106e02e1823f7d01309a972a Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 21 Apr 2026 15:55:29 +0200 Subject: [PATCH] feat(data): add AppSettings entity, migration, and repository --- src/ClaudeDo.Data/ClaudeDoDbContext.cs | 1 + .../AppSettingsEntityConfiguration.cs | 36 +++++++++ .../20260421113614_AddAppSettings.cs | 45 +++++++++++ .../ClaudeDoDbContextModelSnapshot.cs | 74 +++++++++++++++++++ src/ClaudeDo.Data/Models/AppSettingsEntity.cs | 18 +++++ .../Repositories/AppSettingsRepository.cs | 48 ++++++++++++ .../AppSettingsRepositoryTests.cs | 74 +++++++++++++++++++ 7 files changed, 296 insertions(+) create mode 100644 src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs create mode 100644 src/ClaudeDo.Data/Models/AppSettingsEntity.cs create mode 100644 src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Repositories/AppSettingsRepositoryTests.cs diff --git a/src/ClaudeDo.Data/ClaudeDoDbContext.cs b/src/ClaudeDo.Data/ClaudeDoDbContext.cs index 9d95673..57c61c9 100644 --- a/src/ClaudeDo.Data/ClaudeDoDbContext.cs +++ b/src/ClaudeDo.Data/ClaudeDoDbContext.cs @@ -17,6 +17,7 @@ public class ClaudeDoDbContext : DbContext public DbSet Worktrees => Set(); public DbSet TaskRuns => Set(); public DbSet Subtasks => Set(); + public DbSet AppSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs new file mode 100644 index 0000000..44defb4 --- /dev/null +++ b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs @@ -0,0 +1,36 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class AppSettingsEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("app_settings"); + + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever(); + + builder.Property(s => s.DefaultClaudeInstructions) + .HasColumnName("default_claude_instructions").IsRequired().HasDefaultValue(string.Empty); + builder.Property(s => s.DefaultModel) + .HasColumnName("default_model").IsRequired().HasDefaultValue("sonnet"); + builder.Property(s => s.DefaultMaxTurns) + .HasColumnName("default_max_turns").IsRequired().HasDefaultValue(30); + builder.Property(s => s.DefaultPermissionMode) + .HasColumnName("default_permission_mode").IsRequired().HasDefaultValue("bypassPermissions"); + + builder.Property(s => s.WorktreeStrategy) + .HasColumnName("worktree_strategy").IsRequired().HasDefaultValue("sibling"); + builder.Property(s => s.CentralWorktreeRoot) + .HasColumnName("central_worktree_root"); + builder.Property(s => s.WorktreeAutoCleanupEnabled) + .HasColumnName("worktree_auto_cleanup_enabled").IsRequired().HasDefaultValue(false); + builder.Property(s => s.WorktreeAutoCleanupDays) + .HasColumnName("worktree_auto_cleanup_days").IsRequired().HasDefaultValue(7); + + builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs b/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs new file mode 100644 index 0000000..083c4cb --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddAppSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "app_settings", + columns: table => new + { + id = table.Column(type: "INTEGER", nullable: false), + default_claude_instructions = table.Column(type: "TEXT", nullable: false, defaultValue: ""), + default_model = table.Column(type: "TEXT", nullable: false, defaultValue: "sonnet"), + default_max_turns = table.Column(type: "INTEGER", nullable: false, defaultValue: 30), + default_permission_mode = table.Column(type: "TEXT", nullable: false, defaultValue: "bypassPermissions"), + worktree_strategy = table.Column(type: "TEXT", nullable: false, defaultValue: "sibling"), + central_worktree_root = table.Column(type: "TEXT", nullable: true), + worktree_auto_cleanup_enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + worktree_auto_cleanup_days = table.Column(type: "INTEGER", nullable: false, defaultValue: 7) + }, + constraints: table => + { + table.PrimaryKey("PK_app_settings", x => x.id); + }); + + migrationBuilder.InsertData( + table: "app_settings", + columns: new[] { "id", "central_worktree_root", "default_claude_instructions", "default_max_turns", "default_model", "default_permission_mode", "worktree_auto_cleanup_days", "worktree_strategy" }, + values: new object[] { 1, null, "", 30, "sonnet", "bypassPermissions", 7, "sibling" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "app_settings"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 96a3b9e..66256f8 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -17,6 +17,80 @@ namespace ClaudeDo.Data.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 30, + DefaultModel = "sonnet", + DefaultPermissionMode = "bypassPermissions", + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => { b.Property("ListId") diff --git a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs new file mode 100644 index 0000000..45da771 --- /dev/null +++ b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs @@ -0,0 +1,18 @@ +namespace ClaudeDo.Data.Models; + +public sealed class AppSettingsEntity +{ + public const int SingletonId = 1; + + public int Id { get; set; } = SingletonId; + + public string DefaultClaudeInstructions { get; set; } = string.Empty; + public string DefaultModel { get; set; } = "sonnet"; + public int DefaultMaxTurns { get; set; } = 30; + public string DefaultPermissionMode { get; set; } = "bypassPermissions"; + + public string WorktreeStrategy { get; set; } = "sibling"; + public string? CentralWorktreeRoot { get; set; } + public bool WorktreeAutoCleanupEnabled { get; set; } + public int WorktreeAutoCleanupDays { get; set; } = 7; +} diff --git a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs new file mode 100644 index 0000000..ee843a4 --- /dev/null +++ b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs @@ -0,0 +1,48 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +public sealed class AppSettingsRepository +{ + private readonly ClaudeDoDbContext _context; + + public AppSettingsRepository(ClaudeDoDbContext context) => _context = context; + + public async Task GetAsync(CancellationToken ct = default) + { + var row = await _context.AppSettings.AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct); + if (row is not null) return row; + + row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }; + _context.AppSettings.Add(row); + await _context.SaveChangesAsync(ct); + _context.Entry(row).State = EntityState.Detached; + return row; + } + + public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default) + { + var row = await _context.AppSettings + .FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct); + if (row is null) + { + row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }; + _context.AppSettings.Add(row); + } + + row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty; + row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel; + row.DefaultMaxTurns = updated.DefaultMaxTurns; + row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode) + ? "bypassPermissions" : updated.DefaultPermissionMode; + row.WorktreeStrategy = string.IsNullOrWhiteSpace(updated.WorktreeStrategy) ? "sibling" : updated.WorktreeStrategy; + row.CentralWorktreeRoot = string.IsNullOrWhiteSpace(updated.CentralWorktreeRoot) + ? null : updated.CentralWorktreeRoot; + row.WorktreeAutoCleanupEnabled = updated.WorktreeAutoCleanupEnabled; + row.WorktreeAutoCleanupDays = updated.WorktreeAutoCleanupDays; + + await _context.SaveChangesAsync(ct); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/AppSettingsRepositoryTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/AppSettingsRepositoryTests.cs new file mode 100644 index 0000000..e8c5ce3 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Repositories/AppSettingsRepositoryTests.cs @@ -0,0 +1,74 @@ +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Tests.Infrastructure; + +namespace ClaudeDo.Worker.Tests.Repositories; + +public class AppSettingsRepositoryTests : IDisposable +{ + private readonly DbFixture _db = new(); + + public void Dispose() => _db.Dispose(); + + [Fact] + public async Task GetAsync_Returns_Seeded_Defaults() + { + using var ctx = _db.CreateContext(); + var repo = new AppSettingsRepository(ctx); + + var row = await repo.GetAsync(); + + Assert.Equal(AppSettingsEntity.SingletonId, row.Id); + Assert.Equal("sonnet", row.DefaultModel); + Assert.Equal(30, row.DefaultMaxTurns); + Assert.Equal("bypassPermissions", row.DefaultPermissionMode); + Assert.Equal("sibling", row.WorktreeStrategy); + Assert.Null(row.CentralWorktreeRoot); + Assert.False(row.WorktreeAutoCleanupEnabled); + } + + [Fact] + public async Task UpdateAsync_Persists_And_RoundTrips() + { + using (var ctx = _db.CreateContext()) + { + var repo = new AppSettingsRepository(ctx); + await repo.UpdateAsync(new AppSettingsEntity + { + DefaultClaudeInstructions = "be terse", + DefaultModel = "opus", + DefaultMaxTurns = 42, + DefaultPermissionMode = "acceptEdits", + WorktreeStrategy = "central", + CentralWorktreeRoot = "C:/worktrees", + WorktreeAutoCleanupEnabled = true, + WorktreeAutoCleanupDays = 14, + }); + } + + using var readCtx = _db.CreateContext(); + var readRepo = new AppSettingsRepository(readCtx); + var row = await readRepo.GetAsync(); + + Assert.Equal("be terse", row.DefaultClaudeInstructions); + Assert.Equal("opus", row.DefaultModel); + Assert.Equal(42, row.DefaultMaxTurns); + Assert.Equal("acceptEdits", row.DefaultPermissionMode); + Assert.Equal("central", row.WorktreeStrategy); + Assert.Equal("C:/worktrees", row.CentralWorktreeRoot); + Assert.True(row.WorktreeAutoCleanupEnabled); + Assert.Equal(14, row.WorktreeAutoCleanupDays); + } + + [Fact] + public async Task UpdateAsync_Blank_CentralRoot_Stored_As_Null() + { + using var ctx = _db.CreateContext(); + var repo = new AppSettingsRepository(ctx); + + await repo.UpdateAsync(new AppSettingsEntity { CentralWorktreeRoot = " " }); + var row = await repo.GetAsync(); + + Assert.Null(row.CentralWorktreeRoot); + } +}