feat(data): add AppSettings entity, migration, and repository

This commit is contained in:
Mika Kuns
2026-04-21 15:55:29 +02:00
parent 4283c67d81
commit 62a1121571
7 changed files with 296 additions and 0 deletions

View File

@@ -17,6 +17,7 @@ public class ClaudeDoDbContext : DbContext
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{

View File

@@ -0,0 +1,36 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettingsEntity>
{
public void Configure(EntityTypeBuilder<AppSettingsEntity> 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 });
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddAppSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "app_settings",
columns: table => new
{
id = table.Column<int>(type: "INTEGER", nullable: false),
default_claude_instructions = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
default_model = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "sonnet"),
default_max_turns = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 30),
default_permission_mode = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "bypassPermissions"),
worktree_strategy = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "sibling"),
central_worktree_root = table.Column<string>(type: "TEXT", nullable: true),
worktree_auto_cleanup_enabled = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
worktree_auto_cleanup_days = table.Column<int>(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" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "app_settings");
}
}
}

View File

@@ -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<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("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<string>("ListId")

View File

@@ -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;
}

View File

@@ -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<AppSettingsEntity> 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);
}
}

View File

@@ -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);
}
}