Files
gott/Endpoints/SessionEndpoint.cs
T
MelekiandClaude Opus 4.7 ecddbe5f36 fix(openai): ask_user_choice-Intercept, Interleaved Thinking in History, MiniMax-Debug-Log
OpenAI-kompatible Provider (MiniMax, DeepSeek-R1, QwQ, Kimi K2, etc.) verloren bei
jeder neuen Iteration ihre eigene Chain-of-Thought, weil thinking-Messages aus der
DB-History rausgefiltert wurden. Folge: Modell kündigt Action an, ruft Tool aber
nicht auf → Stream endet mit finish_reason:stop ohne Tool-Call. Zusätzlich fehlte
der ask_user_choice-Intercept (Tool wurde als unbekannt abgelehnt) und es gab keine
Diagnose-Möglichkeit für unerwartete Stop-Verhalten.

- ChatService.BuildHistoryFromDb(includeThinking): neuer Parameter, fügt
  thinking-Messages als {type:"thinking"}-Block in die Assistant-Message ein.
  Text/Tool-Use derselben Iteration werden via AppendToLastMessage angehängt
  → ein Assistant-Block enthält [thinking, text, tool_use…].
- OpenAiService.ConvertMessage + StreamChat + ChatNonStreaming: Thinking als
  <think>...</think>-Prefix dem Assistant-Content voranstellen (Per-Iteration und
  beim DB-History-Aufbau). Konvention von MiniMax-Docs, wird auch von DeepSeek-R1,
  QwQ, Kimi K2 verstanden.
- ChatEndpoint, SessionEndpoint, CronjobService, TelegramService, ToolService:
  includeThinking je nach Provider durchgereicht. Anthropic-Pfad bleibt aus
  (lehnt thinking-Blöcke ohne Signature ab).
- OpenAiService: ask_user_choice-Intercept im Streaming-Pfad (analog
  AnthropicService.Streaming) – Tool nicht ausführen, SSE user_choice rausschicken,
  mit stopReason awaiting_user_choice finalisieren und return.
- OpenAiService: MiniMax-SSE-Debug-Log in data/openai-debug/ (JSONL: Header +
  Roh-data-Zeilen + Summary mit promptTokens/completionTokens/finishReason/
  toolCalls/duration) – nur für provider=="minimax", nicht-blockierend.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:19:06 +02:00

718 lines
29 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Gott.Extensions;
using Gott.Models;
using Gott.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Gott.Endpoints;
public static class SessionEndpoint
{
public static void MapSessionEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/sessions", HandleList);
app.MapGet("/api/sessions/{sessionId}", HandleGet);
app.MapPost("/api/sessions", HandleCreate);
app.MapPut("/api/sessions/{sessionId}", HandleUpdate);
app.MapDelete("/api/sessions/{sessionId}", HandleDelete);
app.MapGet("/api/sessions/{sessionId}/system-prompt", HandleSystemPrompt);
app.MapGet("/api/sessions/{sessionId}/system-prompt-blocks", HandleSystemPromptBlocks);
app.MapGet("/api/sessions/{sessionId}/payload-preview", HandlePayloadPreview);
app.MapGet("/api/sessions/{sessionId}/streaming", HandleStreaming);
app.MapPost("/api/sessions/{sessionId}/exec", HandleExec);
app.MapGet("/api/sessions/{sessionId}/git-status", HandleGitStatus);
}
private static async Task HandleList(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var user = await db.GetUser(userId);
var sessionList = await db.GetSessionsWithStats(userId);
// Git-Status parallel für alle Sessions mit Workspace laden
var gitTasks = new Dictionary<int, Task<GitStatusSummary>>();
for (int i = 0; i < sessionList.Count; i++)
{
var resolvedWs = WorkspaceHelper.ResolveWorkspace(sessionList[i].Session, user);
if (!string.IsNullOrEmpty(resolvedWs))
gitTasks[i] = GetGitStatusSummaryAsync(resolvedWs);
}
if (gitTasks.Count > 0)
await Task.WhenAll(gitTasks.Values);
// Ergebnisse zusammenführen
var results = new List<object>();
for (int i = 0; i < sessionList.Count; i++)
{
var sw = sessionList[i];
var s = sw.Session;
object gitStatus = null;
if (gitTasks.TryGetValue(i, out var task) && task.IsCompletedSuccessfully && task.Result != null)
{
var gs = task.Result;
if (gs.Add > 0 || gs.Edit > 0 || gs.Delete > 0 || gs.Outgoing > 0)
gitStatus = new { add = gs.Add, edit = gs.Edit, delete = gs.Delete, outgoing = gs.Outgoing };
}
results.Add(new
{
s.SessionId,
s.Name,
s.Description,
Image = s.ImageSmall ?? s.Image,
s.AgentId,
s.Workspace,
s.Context,
s.CustomCommands,
s.AvatarScale,
s.AvatarOffsetX,
s.AvatarOffsetY,
s.ReviewAgentId,
s.MaxReviewIterations,
s.FinalizerAgentId,
s.PreparerAgentId,
s.UnityMcpUrl,
s.UnityMcpToken,
s.IsDeleted,
s.CreatedAt,
s.UpdatedAt,
sw.MessageCount,
sw.ArchiveMessageCount,
sw.TotalMessageCount,
sw.TelegramConfigured,
sw.TelegramLinked,
GitStatus = gitStatus
});
}
await ctx.Response.WriteAsJsonAsync(results);
}
private static async Task HandleGet(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
await ctx.Response.WriteAsJsonAsync(session);
}
private static async Task HandleCreate(HttpContext ctx, DatabaseService db, ImageService imageService)
{
var userId = ctx.GetUserId();
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
var name = body.TryGetProperty("name", out var n) ? n.GetString() : "Neue Session";
var description = body.TryGetProperty("description", out var d) ? d.GetString() : null;
var imageRaw = body.TryGetProperty("image", out var img) ? img.GetString() : null;
var image = imageService.ResizeDataUrl(imageRaw);
var imageSmall = imageService.ResizeDataUrlSmall(imageRaw);
var agentId = body.TryGetProperty("agentId", out var a) ? a.GetString() : null;
var workspaceRaw = body.TryGetProperty("workspace", out var w) ? w.GetString() : null;
// Relativen Workspace validieren (Path-Traversal verhindern)
var workspace = !string.IsNullOrWhiteSpace(workspaceRaw) && !System.IO.Path.IsPathRooted(workspaceRaw)
? WorkspaceHelper.ValidateRelativeWorkspace(workspaceRaw)
: workspaceRaw;
if (!string.IsNullOrWhiteSpace(workspaceRaw) && workspace == null)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Ungültiger Workspace-Pfad (.. nicht erlaubt)" });
return;
}
var context = body.TryGetProperty("context", out var ctxEl) ? ctxEl.GetString() : null;
var customCommands = body.TryGetProperty("customCommands", out var cc) && cc.ValueKind != JsonValueKind.Null ? cc.GetRawText() : null;
var avatarScale = body.TryGetProperty("avatarScale", out var asc) ? asc.GetDouble() : 1.0;
var avatarOffsetX = body.TryGetProperty("avatarOffsetX", out var aox) ? aox.GetDouble() : 0;
var avatarOffsetY = body.TryGetProperty("avatarOffsetY", out var aoy) ? aoy.GetDouble() : 0;
var reviewAgentId = body.TryGetProperty("reviewAgentId", out var ra) ? ra.GetString() : null;
var maxReviewIterations = body.TryGetProperty("maxReviewIterations", out var mri) ? mri.GetInt32() : 3;
var finalizerAgentId = body.TryGetProperty("finalizerAgentId", out var fa) ? fa.GetString() : null;
var preparerAgentId = body.TryGetProperty("preparerAgentId", out var pra) ? pra.GetString() : null;
var session = await db.CreateSession(userId, name, description, image, imageSmall, agentId, workspace, context, customCommands, avatarScale, avatarOffsetX, avatarOffsetY, reviewAgentId, maxReviewIterations, finalizerAgentId, preparerAgentId);
await ctx.Response.WriteAsJsonAsync(session);
}
private static async Task HandleUpdate(HttpContext ctx, DatabaseService db, ImageService imageService)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var existing = await db.GetSession(sessionId);
if (existing == null || existing.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
// Workspace-Pfad validieren (Path-Traversal verhindern)
if (body.TryGetProperty("workspace", out var wsEl))
{
var wsRaw = wsEl.GetString();
if (!string.IsNullOrWhiteSpace(wsRaw) && !System.IO.Path.IsPathRooted(wsRaw))
{
var validated = WorkspaceHelper.ValidateRelativeWorkspace(wsRaw);
if (validated == null)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Ungültiger Workspace-Pfad (.. nicht erlaubt)" });
return;
}
}
}
await db.UpdateSession(sessionId, body, imageService);
var session = await db.GetSession(sessionId);
await ctx.Response.WriteAsJsonAsync(session);
}
private static async Task HandleDelete(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId)) { ctx.Response.StatusCode = 400; return; }
await db.DeleteSession(sessionId, userId);
ctx.Response.StatusCode = 204;
}
/// <summary>
/// Löst Session, Agent, Workspace und Prompt-Abhängigkeiten auf.
/// Gibt null zurück und setzt StatusCode bei Fehler.
/// </summary>
private static async Task<(SessionRecord session, AgentRecord agent, IEnumerable<MessageRecord> marked,
string memDir, IEnumerable<string> skills, UserRecord user, string workspace, bool planMode)?>
ResolvePromptContext(HttpContext ctx, DatabaseService db, MemoryService memoryService)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId)) { ctx.Response.StatusCode = 400; return null; }
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId) { ctx.Response.StatusCode = 404; return null; }
if (string.IsNullOrEmpty(session.AgentId))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Kein Agent ausgewählt" });
return null;
}
var agent = await db.GetAgent(session.AgentId);
if (agent == null)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Agent nicht gefunden" });
return null;
}
var marked = await db.GetMarkedMessages(sessionId);
var memDir = memoryService.GetAgentMemoryDir(agent.AgentId);
var skills = await db.GetAgentSkills(agent.AgentId);
var user = await db.GetUser(userId);
var workspace = WorkspaceHelper.ResolveWorkspace(session, user);
if (string.IsNullOrEmpty(workspace) && skills.Any())
workspace = WorkspaceHelper.TempDir;
var planMode = ctx.Request.Query.ContainsKey("planMode") && ctx.Request.Query["planMode"] == "true";
return (session, agent, marked, memDir, skills, user, workspace, planMode);
}
private static async Task HandleSystemPrompt(HttpContext ctx, DatabaseService db, MemoryService memoryService, SkillService skillService)
{
var result = await ResolvePromptContext(ctx, db, memoryService);
if (result == null) return;
var (session, agent, marked, memDir, skills, user, workspace, planMode) = result.Value;
var systemPrompt = SystemPromptBuilder.Build(agent, session, marked, memoryService, memDir,
skills, skillService, userPersona: user?.GetEffectivePersona(), resolvedWorkspace: workspace, planMode: planMode);
ctx.Response.ContentType = "text/plain; charset=utf-8";
await ctx.Response.WriteAsync(systemPrompt);
}
private static async Task HandleSystemPromptBlocks(HttpContext ctx, DatabaseService db, MemoryService memoryService, SkillService skillService)
{
var result = await ResolvePromptContext(ctx, db, memoryService);
if (result == null) return;
var (session, agent, marked, memDir, skills, user, workspace, planMode) = result.Value;
var blocks = SystemPromptBuilder.BuildBlocks(agent, session, marked, memoryService, memDir,
skills, skillService, userPersona: user?.GetEffectivePersona(), resolvedWorkspace: workspace, planMode: planMode);
await ctx.Response.WriteAsJsonAsync(blocks.Select(b => new { b.Icon, b.Label, b.Content }));
}
private static async Task HandlePayloadPreview(HttpContext ctx, DatabaseService db, MemoryService memoryService, SkillService skillService, ChatService chatService, ToolService toolService, ServerSettingsService settings)
{
var result = await ResolvePromptContext(ctx, db, memoryService);
if (result == null) return;
var (session, agent, marked, memDir, skills, user, workspace, planMode) = result.Value;
// System-Prompt
var promptBlocks = SystemPromptBuilder.BuildBlocks(agent, session, marked, memoryService, memDir,
skills, skillService, userPersona: user?.GetEffectivePersona(), resolvedWorkspace: workspace, planMode: planMode);
var systemPromptText = string.Join("\n\n", promptBlocks.Select(b => b.Content));
var systemPrompt = new List<object>();
if (!string.IsNullOrWhiteSpace(systemPromptText))
systemPrompt.Add(new { type = "text", text = systemPromptText });
// Model
var modelId = ChatService.ResolveModelId(agent.Model);
// Messages aus der DB (exakt wie beim Chat) – includeThinking je nach Provider
var previewProvider = OpenAiService.IsOpenAiProvider(modelId);
var messages = await chatService.BuildHistoryFromDb(session.SessionId, promptBlocks: promptBlocks, includeThinking: previewProvider);
// Tools (exakt wie AddToolsToPayload es machen würde)
var hasWorkspace = !string.IsNullOrEmpty(workspace);
var includeMemory = agent.UseMemory && !string.IsNullOrEmpty(agent.AgentId);
var includeGitHubSearch = agent.UseGitHubSearch && toolService.HasGitHubToken;
var includeSerperSearch = agent.UseSerperSearch && toolService.HasSerperKey;
var hasTools = hasWorkspace || includeMemory || includeGitHubSearch || includeSerperSearch;
var tools = new List<object>();
if (hasTools)
{
var allTools = toolService.GetToolDefinitions(new ToolDefinitionOptions
{
IncludeFileTools = hasWorkspace, IncludeMemory = includeMemory,
IncludeUserChoice = agent.AllowUserChoice,
IncludeGitHubSearch = includeGitHubSearch, IncludeSerperSearch = includeSerperSearch
});
if (planMode)
allTools = toolService.FilterReadOnlyTools(allTools);
tools.AddRange(allTools);
}
if (!string.IsNullOrEmpty(agent.WebSearchVersion))
tools.Add(new { type = agent.WebSearchVersion, name = "web_search", max_uses = 5 });
var toolChoice = hasTools || !string.IsNullOrEmpty(agent.WebSearchVersion) ? "any" : null;
var effectiveChoice = toolChoice == "any" ? "any" : "auto";
// Payload zusammenbauen (Anthropic-Format)
var payload = new Dictionary<string, object>
{
["model"] = modelId,
["max_tokens"] = 16384,
["messages"] = messages,
["stream"] = true
};
if (systemPrompt.Count > 0)
payload["system"] = systemPrompt;
if (tools.Count > 0)
{
payload["tools"] = tools;
payload["tool_choice"] = new { type = effectiveChoice };
}
ctx.Response.ContentType = "application/json; charset=utf-8";
await ctx.Response.WriteAsync(JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
}
private static async Task HandleStreaming(HttpContext ctx, DatabaseService db, StreamingTracker streamingTracker)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
var isStreaming = streamingTracker.IsStreaming(sessionId);
var chainId = streamingTracker.GetChainId(sessionId);
await ctx.Response.WriteAsJsonAsync(new { streaming = isStreaming, chainId });
}
private static async Task HandleExec(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
var command = body.TryGetProperty("command", out var c) ? c.GetString() : null;
var mode = body.TryGetProperty("mode", out var m) ? m.GetString() ?? "background" : "background";
if (string.IsNullOrWhiteSpace(command))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Kein Befehl angegeben" });
return;
}
var user = await db.GetUser(userId);
var resolvedWs = WorkspaceHelper.ResolveWorkspace(session, user);
var workingDir = !string.IsNullOrWhiteSpace(resolvedWs) ? resolvedWs : WorkspaceHelper.TempDir;
if (mode == "foreground")
{
// Synchron ausführen, auf Ergebnis warten
try
{
string scriptPath = null;
var psi = new ProcessStartInfo
{
FileName = WorkspaceHelper.ShellExecutable,
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
if (WorkspaceHelper.IsWindows)
{
scriptPath = Path.Combine(Path.GetTempPath(), $"gott_exec_{Guid.NewGuid():N}.cmd");
await System.IO.File.WriteAllTextAsync(scriptPath, $"@echo off\r\n{command}\r\n");
psi.Arguments = $"/c \"{scriptPath}\"";
}
else
{
psi.RedirectStandardInput = true;
}
using var proc = System.Diagnostics.Process.Start(psi);
if (proc == null)
{
if (scriptPath != null) try { System.IO.File.Delete(scriptPath); } catch { }
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new { error = "Prozess konnte nicht gestartet werden" });
return;
}
if (!WorkspaceHelper.IsWindows)
{
await proc.StandardInput.WriteAsync(command);
proc.StandardInput.Close();
}
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
var stderrTask = proc.StandardError.ReadToEndAsync();
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
await proc.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
proc.Kill(entireProcessTree: true);
if (scriptPath != null) try { System.IO.File.Delete(scriptPath); } catch { }
await ctx.Response.WriteAsJsonAsync(new { status = "timeout", output = "Timeout nach 30 Sekunden", exitCode = -1 });
return;
}
if (scriptPath != null) try { System.IO.File.Delete(scriptPath); } catch { }
var stdout = await stdoutTask;
var stderr = await stderrTask;
var output = new System.Text.StringBuilder();
if (!string.IsNullOrEmpty(stdout)) output.Append(stdout);
if (!string.IsNullOrEmpty(stderr))
{
if (output.Length > 0) output.Append("\n--- stderr ---\n");
output.Append(stderr);
}
await ctx.Response.WriteAsJsonAsync(new { status = "completed", output = output.ToString(), exitCode = proc.ExitCode });
}
catch (Exception ex)
{
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new { error = ex.Message });
}
}
else
{
// Fire-and-forget
try
{
ProcessStartInfo psi;
if (WorkspaceHelper.IsWindows)
{
var scriptPath = Path.Combine(WorkspaceHelper.TempDir, $"gott-exec-{Guid.NewGuid():N}.bat");
await System.IO.File.WriteAllTextAsync(scriptPath, $"@echo off\r\ncd /d \"{workingDir}\"\r\n{command}\r\ndel \"%~f0\"\r\n");
psi = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c \"{scriptPath}\"",
WorkingDirectory = workingDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
else
{
var scriptPath = Path.Combine(WorkspaceHelper.TempDir, $"gott-exec-{Guid.NewGuid():N}.sh");
await System.IO.File.WriteAllTextAsync(scriptPath, $"#!/bin/bash\ncd {workingDir}\n{command}\nrm -f {scriptPath}\n");
psi = new ProcessStartInfo
{
FileName = "systemd-run",
Arguments = $"--scope --quiet bash {scriptPath}",
WorkingDirectory = workingDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
var process = Process.Start(psi);
if (process != null)
{
process.WaitForExit(500);
}
ctx.Response.StatusCode = 202;
await ctx.Response.WriteAsJsonAsync(new { status = "started", command = command });
}
catch (Exception ex)
{
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new { error = ex.Message });
}
}
}
private static async Task HandleGitStatus(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString();
if (string.IsNullOrEmpty(sessionId))
{
ctx.Response.StatusCode = 400;
return;
}
var session = await db.GetSession(sessionId);
if (session == null || session.UserId != userId)
{
ctx.Response.StatusCode = 404;
return;
}
var user = await db.GetUser(userId);
var workingDir = WorkspaceHelper.ResolveWorkspace(session, user);
if (string.IsNullOrEmpty(workingDir))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Kein Workspace konfiguriert" });
return;
}
var result = await GetGitStatusDetailedAsync(workingDir);
if (result == null)
{
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(new { error = "Git-Status konnte nicht ermittelt werden" });
return;
}
await ctx.Response.WriteAsJsonAsync(result);
}
/// <summary>
/// Ermittelt eine Git-Status-Zusammenfassung (nur Counts) für die Dashboard-Ansicht.
/// Gibt null zurück bei Fehlern (kein Git-Repo, kein git installiert etc.).
/// </summary>
private static async Task<GitStatusSummary> GetGitStatusSummaryAsync(string workingDir)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "git",
Arguments = "status --porcelain",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = Process.Start(psi);
if (proc == null) return null;
var stdout = await proc.StandardOutput.ReadToEndAsync();
await proc.WaitForExitAsync();
if (proc.ExitCode != 0) return null;
int addCount = 0, editCount = 0, deleteCount = 0;
var lines = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
if (line.Length < 4) continue;
var x = line[0];
var y = line[1];
if (x == '?' && y == '?') addCount++;
else if (x == 'D' || y == 'D') deleteCount++;
else if (x == 'A') addCount++;
else editCount++;
}
// Outgoing commits
int outgoing = 0;
try
{
var outPsi = new ProcessStartInfo
{
FileName = "git",
Arguments = "rev-list --count @{u}..HEAD",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var outProc = Process.Start(outPsi);
if (outProc != null)
{
var outResult = (await outProc.StandardOutput.ReadToEndAsync()).Trim();
await outProc.WaitForExitAsync();
if (outProc.ExitCode == 0 && int.TryParse(outResult, out var c))
outgoing = c;
}
}
catch { }
return new GitStatusSummary { Add = addCount, Edit = editCount, Delete = deleteCount, Outgoing = outgoing };
}
catch
{
return null;
}
}
/// <summary>
/// Ermittelt den detaillierten Git-Status (mit Dateinamen) für den Einzel-Endpoint.
/// </summary>
private static async Task<object> GetGitStatusDetailedAsync(string workingDir)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "git",
Arguments = "status --porcelain",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = Process.Start(psi);
if (proc == null) return null;
var stdout = await proc.StandardOutput.ReadToEndAsync();
await proc.WaitForExitAsync();
if (proc.ExitCode != 0) return null;
var add = new List<string>();
var edit = new List<string>();
var delete = new List<string>();
var lines = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
if (line.Length < 4) continue;
var x = line[0];
var y = line[1];
var file = line.Substring(3);
var arrowIdx = file.IndexOf(" -> ", StringComparison.Ordinal);
if (arrowIdx >= 0)
file = file.Substring(arrowIdx + 4);
if (x == '?' && y == '?') add.Add(file);
else if (x == 'D' || y == 'D') delete.Add(file);
else if (x == 'A') add.Add(file);
else edit.Add(file);
}
int outgoing = 0;
try
{
var outPsi = new ProcessStartInfo
{
FileName = "git",
Arguments = "rev-list --count @{u}..HEAD",
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var outProc = Process.Start(outPsi);
if (outProc != null)
{
var outResult = (await outProc.StandardOutput.ReadToEndAsync()).Trim();
await outProc.WaitForExitAsync();
if (outProc.ExitCode == 0 && int.TryParse(outResult, out var count))
outgoing = count;
}
}
catch { }
return new { add, edit, delete, outgoing };
}
catch
{
return null;
}
}
}