using System.ComponentModel; using System.Diagnostics; using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.State; using ClaudeDo.Worker.Worktrees; using Microsoft.EntityFrameworkCore; using ModelContextProtocol.Server; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.External; public sealed record TaskListDto(string Id, string Name, string? WorkingDir); public sealed record DeleteTaskResult(bool Deleted, string Id); public sealed record CancelTaskResult(bool Cancelled, string Id); public sealed record StatusValueDto(string Status, string Meaning); public sealed record TaskDto( string Id, string ListId, string Title, string? Description, string Status, string? Result, string? CreatedBy, DateTime CreatedAt, DateTime? StartedAt, DateTime? FinishedAt, bool IsMyDay, int SortOrder); public sealed record WorktreeInfoDto( string Path, string Branch, string HeadCommit, string BaseCommit, int Ahead, int Behind, bool IsDirty); public sealed record TaskDiffDto( string Content, IReadOnlyList Files, bool Truncated, int TotalBytes); public sealed record MergeTaskResultDto( bool Merged, string? MergeCommit, IReadOnlyList Conflicts); public sealed record WorktreeListItemDto( string? TaskId, string Path, string Branch, string HeadCommit, bool IsDirty, bool MergedIntoMain); public sealed record CleanupWorktreeResult( bool Removed, string WorktreePath, bool BranchDeleted); public sealed record DailyPrepCandidateDto( string Id, string ListId, string ListName, string Title, string? Description, bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt); public sealed record DailyPrepDataDto( int MaxTasks, IReadOnlyList Candidates, IReadOnlyList CurrentMyDay); [McpServerToolType] public sealed class ExternalMcpService { private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly QueueService _queue; private readonly HubBroadcaster _broadcaster; private readonly ITaskStateService _state; private readonly GitService _git; private readonly IDbContextFactory _dbFactory; private readonly WorktreeMaintenanceService _maintenance; private readonly TaskMergeService _merge; public ExternalMcpService( TaskRepository tasks, ListRepository lists, QueueService queue, HubBroadcaster broadcaster, ITaskStateService state, GitService git, IDbContextFactory dbFactory, WorktreeMaintenanceService maintenance, TaskMergeService merge) { _tasks = tasks; _lists = lists; _queue = queue; _broadcaster = broadcaster; _state = state; _git = git; _dbFactory = dbFactory; _maintenance = maintenance; _merge = merge; } [McpServerTool, Description("List all task lists available in ClaudeDo.")] public async Task> ListTaskLists(CancellationToken cancellationToken) { var lists = await _lists.GetAllAsync(cancellationToken); return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList(); } [McpServerTool, Description( "List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " + "Valid status values: Idle, Queued, Running, WaitingForReview, Done, Failed, Cancelled.")] public async Task> ListTasks( string listId, string? createdBy, string? status, CancellationToken cancellationToken) { TaskStatus? statusFilter = null; if (!string.IsNullOrWhiteSpace(status)) { if (!Enum.TryParse(status, ignoreCase: true, out var parsed)) throw new InvalidOperationException( $"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, Done, Failed, Cancelled."); statusFilter = parsed; } var tasks = await _tasks.GetByListIdAsync(listId, cancellationToken); IEnumerable query = tasks; if (createdBy is not null) query = query.Where(t => t.CreatedBy == createdBy); if (statusFilter is not null) query = query.Where(t => t.Status == statusFilter); return query.Select(ToDto).ToList(); } [McpServerTool, Description( "Get a single task by id, including its current status and result. " + "Status lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " + "A successful run lands in WaitingForReview; use review_task to approve, reject, or cancel. " + "Done/Failed/Cancelled tasks can be reset to Idle for re-execution.")] public async Task GetTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); return ToDto(task); } [McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution.")] public async Task AddTask( string listId, string title, string? description = null, string? createdBy = null, bool queueImmediately = false, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(listId)) throw new InvalidOperationException("listId is required."); if (string.IsNullOrWhiteSpace(title)) throw new InvalidOperationException("title is required."); var list = await _lists.GetByIdAsync(listId, cancellationToken) ?? throw new InvalidOperationException($"List {listId} not found."); var entity = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, Description = description, Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = list.DefaultCommitType, CreatedBy = createdBy.NullIfBlank() ?? "mcp", }; await _tasks.AddAsync(entity, cancellationToken); if (queueImmediately) { var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken); if (!enqueue.Ok) throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task."); entity.Status = TaskStatus.Queued; } await _broadcaster.TaskUpdated(entity.Id); return ToDto(entity); } [McpServerTool, Description("Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. Refuses if the task is currently Running.")] public async Task UpdateTask( string taskId, string? title, string? description, string? commitType, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot update a running task. Cancel it first."); if (title is not null) task.Title = title; if (description is not null) task.Description = description; if (commitType is not null) task.CommitType = commitType; await _tasks.UpdateAsync(task, cancellationToken); var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; await _broadcaster.TaskUpdated(taskId); return ToDto(reload); } [McpServerTool, Description( "Append a subtask (step) to a task. orderNum defaults to the end. " + "Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")] public async Task AddSubtask( string taskId, string title, int? orderNum, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(title)) throw new InvalidOperationException("title is required."); await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); var tasks = new TaskRepository(ctx); var subtasks = new SubtaskRepository(ctx); var task = await tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first."); var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken); var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1); await subtasks.AddAsync(new SubtaskEntity { Id = Guid.NewGuid().ToString(), TaskId = taskId, Title = title.Trim(), Completed = false, OrderNum = order, CreatedAt = DateTime.UtcNow, }, cancellationToken); await _broadcaster.TaskUpdated(taskId); return ToDto(task); } [McpServerTool, Description( "Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " + "use run_task_now or cancel_task for execution control, and review_task to act on a WaitingForReview task. " + "Settable: Idle (reset to editable), Queued (enqueue for execution). " + "Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")] public async Task UpdateTaskStatus( string taskId, string status, CancellationToken cancellationToken) { if (!Enum.TryParse(status, ignoreCase: true, out var target)) throw new InvalidOperationException( $"Unknown status '{status}'. Valid values: Idle, Queued, Running, Done, Failed, Cancelled."); var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); switch (target) { case TaskStatus.Idle: await _tasks.ResetToManualAsync(taskId, cancellationToken); await _broadcaster.TaskUpdated(taskId); break; case TaskStatus.Queued: var enqueueResult = await _state.EnqueueAsync(taskId, cancellationToken); if (!enqueueResult.Ok) throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task."); break; default: throw new InvalidOperationException( $"Status '{target}' is not settable externally. Use run_task_now or cancel_task."); } var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; return ToDto(reload); } [McpServerTool, Description( "Review a task that is WaitingForReview. " + "decision='approve' → Done. " + "decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " + "decision='reject_park' → Idle for manual editing (feedback ignored). " + "decision='cancel' → Cancelled. " + "Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued).")] public async Task ReviewTask( string taskId, string decision, string? feedback, CancellationToken cancellationToken) { _ = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); TransitionResult result = decision.Trim().ToLowerInvariant() switch { "approve" => await _state.ApproveReviewAsync(taskId, cancellationToken), "reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken), "reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken), "cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken), _ => throw new InvalidOperationException( $"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."), }; if (!result.Ok) throw new InvalidOperationException(result.Reason ?? "Review action failed."); return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!); } [McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")] public async Task RunTaskNow(string taskId, CancellationToken cancellationToken) { try { await _queue.RunNow(taskId); } catch (InvalidOperationException) { throw new InvalidOperationException("Override slot busy. Try again later."); } catch (KeyNotFoundException) { throw new InvalidOperationException($"Task {taskId} not found."); } await _broadcaster.TaskUpdated(taskId); } [McpServerTool, Description("Cancel a running task. Returns { cancelled: true, id } if the task was running and cancellation was requested; cancelled is false if the task was not running.")] public async Task CancelTask(string taskId, CancellationToken cancellationToken) { var cancelled = _queue.CancelTask(taskId); if (cancelled) await _broadcaster.TaskUpdated(taskId); return new CancelTaskResult(cancelled, taskId); } [McpServerTool, Description("Delete a task. Returns { deleted: true, id } on success. Throws if the task is not found or is currently Running — cancel it first.")] public async Task DeleteTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot delete a running task. Cancel it first."); await _tasks.DeleteAsync(taskId, cancellationToken); await _broadcaster.TaskUpdated(taskId); return new DeleteTaskResult(true, taskId); } // ── Status reference ───────────────────────────────────────────────────── [McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")] public Task> GetTaskStatusValues() => Task.FromResult>([ new("Idle", "Not yet queued; task is editable and will not run until enqueued."), new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."), new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."), new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."), new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."), new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."), new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."), ]); // ── Worktree / git tools ────────────────────────────────────────────────── [McpServerTool, Description( "Get git worktree details for a task: path, branch, headCommit (current HEAD SHA), " + "baseCommit (SHA where the branch was created), ahead (commits on branch since base), " + "behind (commits on main not yet on this branch; 0 if 'main' ref is unreachable), " + "isDirty (has uncommitted changes in the worktree directory). " + "Throws if the task or its worktree does not exist.")] public async Task GetTaskWorktree(string taskId, CancellationToken cancellationToken) { var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken); var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit) ? wt.HeadCommit : await TryRunGitAsync(wt.Path, ["rev-parse", "HEAD"], cancellationToken) ?? wt.BaseCommit; var isDirty = Directory.Exists(wt.Path) && await _git.HasChangesAsync(wt.Path, cancellationToken); var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken); var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken); return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty); } [McpServerTool, Description( "Get the diff for a task's worktree relative to its base commit. " + "stat=false (default): returns the full unified diff, capped at 200 KB (truncated=true when larger). " + "stat=true: returns a --stat summary (changed files with insertion/deletion counts). " + "files always lists the changed file paths regardless of stat mode. " + "totalBytes is the uncapped diff size (useful when truncated=true). " + "Throws if the task has no worktree or the worktree directory is missing from disk.")] public async Task GetTaskDiff( string taskId, bool stat = false, CancellationToken cancellationToken = default) { var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken); if (!Directory.Exists(wt.Path)) throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}"); const int maxBytes = 200 * 1024; if (stat) { var diffStat = await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", cancellationToken); return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length); } var diff = await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, cancellationToken); var files = ParseDiffFileNames(diff); if (diff.Length <= maxBytes) return new TaskDiffDto(diff, files, false, diff.Length); return new TaskDiffDto(diff[..maxBytes], files, true, diff.Length); } [McpServerTool, Description( "Merge a task's worktree branch into targetBranch (default: main). " + "noFf=true (default): always creates a merge commit (--no-ff). " + "dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " + "Refuses if task status is not Done (status values: Idle, Queued, Running, Done, Failed, Cancelled). " + "On success: merged=true, mergeCommit contains the new merge commit SHA. " + "On conflict: the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files.")] public async Task MergeTask( string taskId, string targetBranch = "main", bool noFf = true, bool dryRun = false, CancellationToken cancellationToken = default) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status != TaskStatus.Done) throw new InvalidOperationException( $"Task must be Done to merge (current status: {task.Status}). " + "Valid statuses for merge: Done."); var list = await _lists.GetByIdAsync(task.ListId, cancellationToken); if (dryRun) { using var ctx = _dbFactory.CreateDbContext(); var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} has no worktree."); if (wt.State != WorktreeState.Active) throw new InvalidOperationException( $"Worktree state must be Active to merge (current: {wt.State})."); return new MergeTaskResultDto(false, null, []); } var commitMessage = $"Merge task branch for: {task.Title}"; var result = await _merge.MergeAsync( taskId, targetBranch, removeWorktree: false, commitMessage, cancellationToken); if (result.Status == TaskMergeService.StatusMerged) { string? mergeCommit = null; try { if (!string.IsNullOrWhiteSpace(list?.WorkingDir) && Directory.Exists(list.WorkingDir)) mergeCommit = await _git.RevParseHeadAsync(list.WorkingDir, cancellationToken); } catch { /* mergeCommit is optional */ } return new MergeTaskResultDto(true, mergeCommit, []); } if (result.Status == TaskMergeService.StatusConflict) return new MergeTaskResultDto(false, null, result.ConflictFiles); throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}"); } [McpServerTool, Description( "List all ClaudeDo-tracked worktrees. " + "Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " + "isDirty (has uncommitted changes), mergedIntoMain (worktree state is Merged). " + "Only worktrees recorded in the ClaudeDo database are returned.")] public async Task> ListWorktrees(CancellationToken cancellationToken) { var rows = await _maintenance.GetOverviewAsync(null, cancellationToken); var results = await Task.WhenAll(rows.Select(async row => { var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken); var headCommit = row.PathExistsOnDisk ? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "") : ""; return new WorktreeListItemDto( row.TaskId, row.Path, row.BranchName, headCommit, isDirty, row.State == WorktreeState.Merged); })); return results; } [McpServerTool, Description( "Remove a task's worktree directory and delete its git branch. " + "force=false (default): refuses if the worktree has uncommitted changes or the task is Running. " + "force=true: removes even a dirty worktree (uncommitted changes are lost); task must not be Running. " + "Returns removed=true on success; branchDeleted reflects whether the branch was also removed.")] public async Task CleanupTaskWorktree( string taskId, bool force = false, CancellationToken cancellationToken = default) { using var ctx = _dbFactory.CreateDbContext(); var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} has no worktree."); if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot remove worktree of a running task."); if (!force && Directory.Exists(wt.Path)) { var isDirty = await _git.HasChangesAsync(wt.Path, cancellationToken); if (isDirty) throw new InvalidOperationException( "Worktree has uncommitted changes. Use force=true to remove anyway (changes will be lost)."); } var path = wt.Path; var result = await _maintenance.ForceRemoveAsync(taskId, cancellationToken); return new CleanupWorktreeResult(result.Removed, path, result.Removed); } // ── Daily prep ─────────────────────────────────────────────────────────── [McpServerTool, Description( "Daily prep: returns the open tasks eligible for today's MyDay selection. " + "candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " + "currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " + "maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")] public async Task GetDailyPrepCandidates(CancellationToken cancellationToken) { await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken); var excludes = DailyPrepFilter.ParseExcludes(settings.ReportExcludedPaths); var maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks; var idle = await ctx.Tasks .AsNoTracking() .Include(t => t.List) .Where(t => t.Status == TaskStatus.Idle) .ToListAsync(cancellationToken); var currentMyDay = idle .Where(t => t.IsMyDay) .OrderBy(t => t.SortOrder) .Select(ToCandidate) .ToList(); var candidates = idle .Where(t => !t.IsMyDay && t.BlockedByTaskId == null && DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes)) .OrderBy(t => t.CreatedAt) .Select(ToCandidate) .ToList(); return new DailyPrepDataDto(maxTasks, candidates, currentMyDay); } [McpServerTool, Description( "Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " + "(use consecutive sortOrder values to keep related tasks together). " + "Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " + "clearing (isMyDay=false) is always allowed.")] public async Task SetMyDay( string taskId, bool isMyDay, int? sortOrder, CancellationToken cancellationToken) { await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (isMyDay && !task.IsMyDay) { var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken); var max = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks; var openMyDay = await ctx.Tasks.CountAsync( t => t.IsMyDay && t.Status == TaskStatus.Idle, cancellationToken); if (openMyDay >= max) throw new InvalidOperationException( $"MyDay limit {max} reached. Clear a task before adding another."); } task.IsMyDay = isMyDay; if (sortOrder is not null) task.SortOrder = sortOrder.Value; await ctx.SaveChangesAsync(cancellationToken); await _broadcaster.TaskUpdated(taskId); return ToDto(task); } private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new( t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description, t.IsStarred, t.ScheduledFor, t.CreatedAt); // ── Private helpers ─────────────────────────────────────────────────────── private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync( string taskId, CancellationToken ct) { using var ctx = _dbFactory.CreateDbContext(); var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} not found."); var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException("List not found."); var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} has no worktree."); return (task, list, wt); } private async Task TryGetIsDirtyAsync(string path, CancellationToken ct) { try { return await _git.HasChangesAsync(path, ct); } catch { return false; } } // Minimal git runner for operations not covered by GitService (rev-list --count, rev-parse from worktree). private static async Task TryRunGitAsync(string dir, string[] args, CancellationToken ct) { try { var psi = new ProcessStartInfo("git") { UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true, }; psi.ArgumentList.Add("-C"); psi.ArgumentList.Add(dir); foreach (var a in args) psi.ArgumentList.Add(a); using var proc = Process.Start(psi)!; await using var _ = ct.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { } }); var stdout = await proc.StandardOutput.ReadToEndAsync(); await proc.WaitForExitAsync(CancellationToken.None); ct.ThrowIfCancellationRequested(); return proc.ExitCode == 0 ? stdout.Trim() : null; } catch (OperationCanceledException) { throw; } catch { return null; } } private static async Task GitRevListCountAsync(string dir, string range, CancellationToken ct) { var result = await TryRunGitAsync(dir, ["rev-list", "--count", range], ct); return int.TryParse(result, out var n) ? n : 0; } private static IReadOnlyList ParseDiffFileNames(string diff) { var files = new List(); foreach (var line in diff.Split('\n')) { var s = line.TrimEnd('\r'); if (s.StartsWith("+++ b/", StringComparison.Ordinal)) files.Add(s[6..]); } return files; } private static IReadOnlyList ParseDiffStatFileNames(string stat) { var files = new List(); foreach (var line in stat.Split('\n')) { var idx = line.IndexOf('|'); if (idx > 0) files.Add(line[..idx].Trim()); } return files; } private static TaskDto ToDto(TaskEntity t) => new( t.Id, t.ListId, t.Title, t.Description, t.Status.ToString(), t.Result, t.CreatedBy, t.CreatedAt, t.StartedAt, t.FinishedAt, t.IsMyDay, t.SortOrder); } internal static class DailyPrepFilter { public static string[] ParseExcludes(string? json) { if (string.IsNullOrWhiteSpace(json)) return []; try { var list = System.Text.Json.JsonSerializer.Deserialize>(json); return list is null ? [] : list.Select(Normalize).Where(p => p.Length > 0).ToArray(); } catch (System.Text.Json.JsonException) { return []; } } public static bool IsIncludedRepo(string? workingDir, string[] excludes) { if (string.IsNullOrWhiteSpace(workingDir)) return false; var norm = Normalize(workingDir); return !excludes.Any(p => norm.StartsWith(p, StringComparison.OrdinalIgnoreCase)); } private static string Normalize(string path) => path.Trim().Replace('/', '\\').TrimEnd('\\'); }