chore(claude-do): merge fix(worktrees): Merge-Erkennung, Cleanup leerer Verzeichniss
ClaudeDo-Task: fad77fdc-d166-41a5-971c-fac16d84ab2c
This commit is contained in:
@@ -489,6 +489,13 @@ public sealed class GitService
|
|||||||
return head.StartsWith("origin/", StringComparison.Ordinal) ? head["origin/".Length..] : head;
|
return head.StartsWith("origin/", StringComparison.Ordinal) ? head["origin/".Length..] : head;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Absolute path of the <c>.git</c> dir shared by all worktrees of the repo owning <paramref name="dir"/>, or null.</summary>
|
||||||
|
public async Task<string?> GetCommonDirAsync(string dir, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, _) = await RunGitAsync(dir, ["rev-parse", "--path-format=absolute", "--git-common-dir"], ct);
|
||||||
|
return exitCode == 0 && !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary><c>git merge --ff-only</c>: moves the checked-out branch to <paramref name="sourceRef"/> or fails without touching it.</summary>
|
/// <summary><c>git merge --ff-only</c>: moves the checked-out branch to <paramref name="sourceRef"/> or fails without touching it.</summary>
|
||||||
public async Task<(int ExitCode, string Stderr)> MergeFastForwardOnlyAsync(
|
public async Task<(int ExitCode, string Stderr)> MergeFastForwardOnlyAsync(
|
||||||
string worktreePath, string sourceRef, CancellationToken ct = default)
|
string worktreePath, string sourceRef, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ public record WorktreeOverviewDto(
|
|||||||
string? DiffStat,
|
string? DiffStat,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
bool PathExistsOnDisk,
|
bool PathExistsOnDisk,
|
||||||
int TaskNumber = 0);
|
int TaskNumber = 0,
|
||||||
|
bool ContentInMain = false);
|
||||||
|
|
||||||
public record ForceRemoveResultDto(bool Removed, string? Reason);
|
public record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||||
|
|
||||||
|
|||||||
@@ -555,6 +555,8 @@
|
|||||||
"resolve": "Lösen",
|
"resolve": "Lösen",
|
||||||
"phantom": "Phantom",
|
"phantom": "Phantom",
|
||||||
"phantomTooltip": "Verzeichnis fehlt auf der Festplatte",
|
"phantomTooltip": "Verzeichnis fehlt auf der Festplatte",
|
||||||
|
"mergedExternal": "gemergt (extern)",
|
||||||
|
"mergedExternalTooltip": "Die Commits sind schon im Default-Branch (lokaler origin-Stand, ohne Fetch), z.B. per Squash-PR. \"Abgeschlossene aufräumen\" entfernt ihn.",
|
||||||
"ctxShowDiff": "Diff anzeigen",
|
"ctxShowDiff": "Diff anzeigen",
|
||||||
"ctxOpenInExplorer": "Im Explorer öffnen",
|
"ctxOpenInExplorer": "Im Explorer öffnen",
|
||||||
"ctxJumpToTask": "Zur Aufgabe springen",
|
"ctxJumpToTask": "Zur Aufgabe springen",
|
||||||
|
|||||||
@@ -555,6 +555,8 @@
|
|||||||
"resolve": "Resolve",
|
"resolve": "Resolve",
|
||||||
"phantom": "phantom",
|
"phantom": "phantom",
|
||||||
"phantomTooltip": "Directory missing on disk",
|
"phantomTooltip": "Directory missing on disk",
|
||||||
|
"mergedExternal": "merged (external)",
|
||||||
|
"mergedExternalTooltip": "Its commits are already in the default branch (local origin ref, not fetched) — e.g. a squash PR. \"Cleanup finished\" removes it.",
|
||||||
"ctxShowDiff": "Show diff",
|
"ctxShowDiff": "Show diff",
|
||||||
"ctxOpenInExplorer": "Open in Explorer",
|
"ctxOpenInExplorer": "Open in Explorer",
|
||||||
"ctxJumpToTask": "Jump to task",
|
"ctxJumpToTask": "Jump to task",
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string? _diffStat;
|
[ObservableProperty] private string? _diffStat;
|
||||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt;
|
[ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt;
|
||||||
[ObservableProperty] private bool _pathExistsOnDisk;
|
[ObservableProperty] private bool _pathExistsOnDisk;
|
||||||
|
/// <summary>Not Merged in ClaudeDo, but the branch's commits are already in the default branch (e.g. a squash PR).</summary>
|
||||||
|
[ObservableProperty] private bool _contentInMain;
|
||||||
[ObservableProperty] private bool _isSelected;
|
[ObservableProperty] private bool _isSelected;
|
||||||
[ObservableProperty] private bool _isChecked;
|
[ObservableProperty] private bool _isChecked;
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -374,6 +376,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
|||||||
ListId = d.ListId, ListName = d.ListName,
|
ListId = d.ListId, ListName = d.ListName,
|
||||||
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
|
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
|
||||||
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
|
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
|
||||||
|
ContentInMain = d.ContentInMain,
|
||||||
};
|
};
|
||||||
|
|
||||||
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
|
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
|
||||||
|
|||||||
@@ -77,6 +77,11 @@
|
|||||||
<TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.phantom}" Foreground="{DynamicResource StatusErrorBrush}"
|
<TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.phantom}" Foreground="{DynamicResource StatusErrorBrush}"
|
||||||
IsVisible="{Binding !PathExistsOnDisk}"
|
IsVisible="{Binding !PathExistsOnDisk}"
|
||||||
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
|
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
|
||||||
|
<TextBlock Classes="meta" Text="•"
|
||||||
|
IsVisible="{Binding ContentInMain}"/>
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.mergedExternal}" Foreground="{DynamicResource StatusDoneBrush}"
|
||||||
|
IsVisible="{Binding ContentInMain}"
|
||||||
|
ToolTip.Tip="{loc:Tr modals.worktreesOverview.mergedExternalTooltip}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
|
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
|
||||||
|
|||||||
+13
-8
@@ -23,7 +23,7 @@ public sealed record TaskDiffDto(
|
|||||||
|
|
||||||
public sealed record WorktreeListItemDto(
|
public sealed record WorktreeListItemDto(
|
||||||
string? TaskId, int? Number, string Path, string Branch,
|
string? TaskId, int? Number, string Path, string Branch,
|
||||||
string HeadCommit, bool IsDirty, bool MergedIntoMain);
|
string HeadCommit, bool IsDirty, bool MergedIntoMain, bool ContentInMain = false);
|
||||||
|
|
||||||
public sealed record CleanupWorktreeResult(
|
public sealed record CleanupWorktreeResult(
|
||||||
bool Removed, string WorktreePath, bool BranchDeleted, int? Number = null);
|
bool Removed, string WorktreePath, bool BranchDeleted, int? Number = null);
|
||||||
@@ -152,27 +152,31 @@ public sealed class WorktreeMcpTools
|
|||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " +
|
"Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " +
|
||||||
"the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk." +
|
"the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk or is not a " +
|
||||||
|
"git worktree. contentInMain: the branch's own commits are already in the default branch (merged, " +
|
||||||
|
"fast-forwarded or squash-merged outside ClaudeDo), checked against the LOCAL origin/<default> (else " +
|
||||||
|
"main/master) without fetching -- fetch first for a current answer. mergedIntoMain = merged via ClaudeDo " +
|
||||||
|
"or contentInMain." +
|
||||||
McpToolDocs.ProgressHint)]
|
McpToolDocs.ProgressHint)]
|
||||||
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(
|
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(
|
||||||
CancellationToken cancellationToken = default,
|
CancellationToken cancellationToken = default,
|
||||||
IProgress<ProgressNotificationValue>? progress = null)
|
IProgress<ProgressNotificationValue>? progress = null)
|
||||||
{
|
{
|
||||||
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
|
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
|
||||||
// One git status + one rev-parse per row, run concurrently -- with many tracked worktrees
|
// One git status + one rev-parse per row (on top of GetOverviewAsync's content-in-main check), run concurrently -- with many tracked worktrees
|
||||||
// (this tool's whole point is surfacing leftovers nobody cleaned up) that can still take a
|
// (this tool's whole point is surfacing leftovers nobody cleaned up) that can still take a
|
||||||
// while, so this reports on the same elapsed-time cadence as a single long git call rather
|
// while, so this reports on the same elapsed-time cadence as a single long git call rather
|
||||||
// than per-row (rows finish out of order under Task.WhenAll, so there's no natural i/n).
|
// than per-row (rows finish out of order under Task.WhenAll, so there's no natural i/n).
|
||||||
var results = await ProgressReporter.RunAsync(
|
var results = await ProgressReporter.RunAsync(
|
||||||
Task.WhenAll(rows.Select(async row =>
|
Task.WhenAll(rows.Select(async row =>
|
||||||
{
|
{
|
||||||
var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken);
|
var isDirty = row.IsGitWorktree && await TryGetIsDirtyAsync(row.Path, cancellationToken);
|
||||||
var headCommit = row.PathExistsOnDisk
|
var headCommit = row.IsGitWorktree
|
||||||
? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
|
? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
|
||||||
: "";
|
: "";
|
||||||
return new WorktreeListItemDto(
|
return new WorktreeListItemDto(
|
||||||
row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit,
|
row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit,
|
||||||
isDirty, row.State == WorktreeState.Merged);
|
isDirty, row.State == WorktreeState.Merged || row.ContentInMain, row.ContentInMain);
|
||||||
})),
|
})),
|
||||||
ProgressReportInterval, progress, "surveying worktrees");
|
ProgressReportInterval, progress, "surveying worktrees");
|
||||||
return results;
|
return results;
|
||||||
@@ -180,7 +184,7 @@ public sealed class WorktreeMcpTools
|
|||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " +
|
"Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " +
|
||||||
"went too." + McpToolDocs.NotWhileRunning)]
|
"is gone afterwards (also true when it was already deleted)." + McpToolDocs.NotWhileRunning)]
|
||||||
public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
|
public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
|
||||||
string taskId,
|
string taskId,
|
||||||
[Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " +
|
[Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " +
|
||||||
@@ -200,7 +204,8 @@ public sealed class WorktreeMcpTools
|
|||||||
if (task.Status == TaskStatus.Running)
|
if (task.Status == TaskStatus.Running)
|
||||||
throw new InvalidOperationException("Cannot remove worktree of a running task.");
|
throw new InvalidOperationException("Cannot remove worktree of a running task.");
|
||||||
|
|
||||||
if (!force && Directory.Exists(wt.Path))
|
// Only a real checkout can be dirty; git status in an empty leftover dir fails (or walks up into an enclosing repo).
|
||||||
|
if (!force && WorktreeMaintenanceService.IsGitWorktree(wt.Path))
|
||||||
{
|
{
|
||||||
var isDirty = await ProgressReporter.RunAsync(
|
var isDirty = await ProgressReporter.RunAsync(
|
||||||
_git.HasChangesAsync(wt.Path, cancellationToken), ProgressReportInterval, progress, "checking worktree for changes");
|
_git.HasChangesAsync(wt.Path, cancellationToken), ProgressReportInterval, progress, "checking worktree for changes");
|
||||||
|
|||||||
@@ -467,7 +467,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
return rows.Select(r => new WorktreeOverviewDto(
|
return rows.Select(r => new WorktreeOverviewDto(
|
||||||
r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName,
|
r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName,
|
||||||
r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk,
|
r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk,
|
||||||
r.TaskNumber)).ToList();
|
r.TaskNumber, r.ContentInMain)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState)
|
public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
@@ -44,12 +45,28 @@ public sealed class WorktreeMaintenanceService
|
|||||||
join t in context.Tasks on w.TaskId equals t.Id
|
join t in context.Tasks on w.TaskId equals t.Id
|
||||||
join l in context.Lists on t.ListId equals l.Id
|
join l in context.Lists on t.ListId equals l.Id
|
||||||
where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded
|
where w.State == WorktreeState.Merged || w.State == WorktreeState.Discarded
|
||||||
select new { Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir), ListId = t.ListId };
|
|| (w.State == WorktreeState.Active && t.Status != ClaudeDo.Data.Models.TaskStatus.Running)
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
Row = new WorktreeRow(w.TaskId, w.Path, w.BranchName, l.WorkingDir),
|
||||||
|
ListId = t.ListId, w.State, w.BaseCommit,
|
||||||
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(listId))
|
if (!string.IsNullOrEmpty(listId))
|
||||||
query = query.Where(x => x.ListId == listId);
|
query = query.Where(x => x.ListId == listId);
|
||||||
|
|
||||||
var rows = await query.AsNoTracking().Select(x => x.Row).ToListAsync(ct);
|
var candidates = await query.AsNoTracking().ToListAsync(ct);
|
||||||
|
var rows = new List<WorktreeRow>();
|
||||||
|
foreach (var c in candidates)
|
||||||
|
{
|
||||||
|
// An Active row only counts as finished when it was merged outside ClaudeDo (e.g. a
|
||||||
|
// squash PR) and holds nothing uncommitted -- TryRemoveAsync would delete that too.
|
||||||
|
if (c.State != WorktreeState.Active
|
||||||
|
|| (IsGitWorktree(c.Row.Path)
|
||||||
|
&& await IsContentInMainAsync(c.Row.Path, c.BaseCommit, ct)
|
||||||
|
&& !await TryHasChangesAsync(c.Row.Path, ct)))
|
||||||
|
rows.Add(c.Row);
|
||||||
|
}
|
||||||
|
|
||||||
int removed = 0;
|
int removed = 0;
|
||||||
var removedTaskIds = new List<string>();
|
var removedTaskIds = new List<string>();
|
||||||
@@ -114,10 +131,100 @@ public sealed class WorktreeMaintenanceService
|
|||||||
|
|
||||||
var rows = await query.AsNoTracking().ToListAsync(ct);
|
var rows = await query.AsNoTracking().ToListAsync(ct);
|
||||||
|
|
||||||
return rows.Select(x => new WorktreeOverviewRow(
|
return await Task.WhenAll(rows.Select(async x =>
|
||||||
x.TaskId, x.Number, x.Title, x.Status, x.ListId, x.ListName,
|
{
|
||||||
x.Path, x.BranchName, x.BaseCommit ?? "", x.State, x.DiffStat, x.CreatedAt,
|
var isGitWorktree = IsGitWorktree(x.Path);
|
||||||
PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path))).ToList();
|
var contentInMain = isGitWorktree
|
||||||
|
&& (x.State is WorktreeState.Active or WorktreeState.Kept)
|
||||||
|
&& await IsContentInMainAsync(x.Path, x.BaseCommit, ct);
|
||||||
|
return new WorktreeOverviewRow(
|
||||||
|
x.TaskId, x.Number, x.Title, x.Status, x.ListId, x.ListName,
|
||||||
|
x.Path, x.BranchName, x.BaseCommit ?? "", x.State, x.DiffStat, x.CreatedAt,
|
||||||
|
PathExistsOnDisk: !string.IsNullOrWhiteSpace(x.Path) && Directory.Exists(x.Path),
|
||||||
|
isGitWorktree, contentInMain);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when <paramref name="path"/> holds a git checkout (<c>.git</c> file or dir), not just a directory.</summary>
|
||||||
|
// A bare directory check isn't enough: git walks up from an empty dir into whatever repo encloses it.
|
||||||
|
internal static bool IsGitWorktree(string? path) =>
|
||||||
|
!string.IsNullOrWhiteSpace(path)
|
||||||
|
&& (File.Exists(Path.Combine(path, ".git")) || Directory.Exists(Path.Combine(path, ".git")));
|
||||||
|
|
||||||
|
// ponytail: 30s TTL per path -- the overview modal reloads every 4s and this costs ~5 git
|
||||||
|
// processes per row; key on (path, HEAD, target sha) if a stale answer ever matters.
|
||||||
|
private static readonly TimeSpan ContentInMainTtl = TimeSpan.FromSeconds(30);
|
||||||
|
private readonly ConcurrentDictionary<string, (DateTime At, bool Result)> _contentInMainCache = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The worktree's own commits are already in the default branch -- HEAD is an ancestor of it, or
|
||||||
|
/// merging HEAD would not change its tree (squash PR). Checks local refs only, never fetches.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> IsContentInMainAsync(string path, string? baseCommit, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(baseCommit)) return false;
|
||||||
|
if (_contentInMainCache.TryGetValue(path, out var hit) && DateTime.UtcNow - hit.At < ContentInMainTtl)
|
||||||
|
return hit.Result;
|
||||||
|
|
||||||
|
bool result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// No commits beyond the base: trivially "in main", but there is nothing merged.
|
||||||
|
if (await _git.IsAncestorAsync(path, "HEAD", baseCommit, ct) != false) result = false;
|
||||||
|
else if (await ResolveDefaultRefAsync(path, ct) is not { } target) result = false;
|
||||||
|
else if (await _git.IsAncestorAsync(path, "HEAD", target, ct) == true) result = true;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var preview = await _git.PreviewMergeAsync(path, target, "HEAD", ct);
|
||||||
|
result = preview.Clean
|
||||||
|
&& preview.TreeOid == await _git.RevParseAsync(path, $"{target}^{{tree}}", ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "content-in-main check failed for {Path}", path);
|
||||||
|
result = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_contentInMainCache[path] = (DateTime.UtcNow, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>origin/HEAD's branch, else the first existing of origin/main, origin/master, main, master.</summary>
|
||||||
|
private async Task<string?> ResolveDefaultRefAsync(string dir, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var remoteHead = await _git.GetRemoteHeadBranchAsync(dir, ct);
|
||||||
|
string[] candidates = remoteHead is null
|
||||||
|
? ["origin/main", "origin/master", "main", "master"]
|
||||||
|
: [$"origin/{remoteHead}"];
|
||||||
|
foreach (var c in candidates)
|
||||||
|
{
|
||||||
|
try { await _git.RevParseAsync(dir, $"{c}^{{commit}}", ct); return c; }
|
||||||
|
catch (InvalidOperationException) { }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryHasChangesAsync(string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Can't tell -> treat as dirty, so a bulk cleanup never deletes it.
|
||||||
|
try { return await _git.HasChangesAsync(path, ct); }
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The repo owning the worktree (parent of its git-common-dir); the list's working dir when the path is no git checkout anymore.</summary>
|
||||||
|
private async Task<string?> ResolveOwnerRepoAsync(WorktreeRow row, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!IsGitWorktree(row.Path)) return row.WorkingDir;
|
||||||
|
var commonDir = await _git.GetCommonDirAsync(row.Path, ct);
|
||||||
|
if (commonDir is null) return row.WorkingDir;
|
||||||
|
var full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(commonDir));
|
||||||
|
// Non-bare repo: .../<repo>/.git -> <repo>. A bare repo's common dir is the repo itself.
|
||||||
|
return Path.GetFileName(full).Equals(".git", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? Path.GetDirectoryName(full)
|
||||||
|
: full;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ForceRemoveResult> ForceRemoveAsync(string taskId, CancellationToken ct = default)
|
public async Task<ForceRemoveResult> ForceRemoveAsync(string taskId, CancellationToken ct = default)
|
||||||
@@ -145,7 +252,10 @@ public sealed class WorktreeMaintenanceService
|
|||||||
|
|
||||||
private async Task<(bool Removed, bool BranchDeleted)> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
|
private async Task<(bool Removed, bool BranchDeleted)> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);
|
// The list's working dir isn't necessarily the repo that owns the worktree (a list can point
|
||||||
|
// at another repo than the task ran in) -- prune/branch -D there would silently miss.
|
||||||
|
var repoDir = await ResolveOwnerRepoAsync(row, ct);
|
||||||
|
var repoDirExists = !string.IsNullOrWhiteSpace(repoDir) && Directory.Exists(repoDir);
|
||||||
bool dirRemoved;
|
bool dirRemoved;
|
||||||
|
|
||||||
if (repoDirExists)
|
if (repoDirExists)
|
||||||
@@ -153,7 +263,7 @@ public sealed class WorktreeMaintenanceService
|
|||||||
dirRemoved = true;
|
dirRemoved = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _git.WorktreeRemoveAsync(row.WorkingDir!, row.Path, force, ct);
|
await _git.WorktreeRemoveAsync(repoDir!, row.Path, force, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -195,21 +305,24 @@ public sealed class WorktreeMaintenanceService
|
|||||||
bool branchDeleted = false;
|
bool branchDeleted = false;
|
||||||
if (repoDirExists)
|
if (repoDirExists)
|
||||||
{
|
{
|
||||||
try { await _git.WorktreePruneAsync(row.WorkingDir!, ct); }
|
try { await _git.WorktreePruneAsync(repoDir!, ct); }
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "git worktree prune failed for {Repo}", row.WorkingDir); }
|
catch (Exception ex) { _logger.LogWarning(ex, "git worktree prune failed for {Repo}", repoDir); }
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(row.BranchName))
|
if (!string.IsNullOrWhiteSpace(row.BranchName))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _git.BranchDeleteAsync(row.WorkingDir!, row.BranchName, force: true, ct);
|
await _git.BranchDeleteAsync(repoDir!, row.BranchName, force: true, ct);
|
||||||
branchDeleted = true;
|
branchDeleted = true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Already deleted -- the normal case once the branch has been merged.
|
// Already deleted -- the normal case once the branch has been merged.
|
||||||
if (IsBranchAlreadyGone(ex))
|
if (IsBranchAlreadyGone(ex))
|
||||||
|
{
|
||||||
_logger.LogDebug(ex, "branch {Branch} was already deleted", row.BranchName);
|
_logger.LogDebug(ex, "branch {Branch} was already deleted", row.BranchName);
|
||||||
|
branchDeleted = true;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
_logger.LogWarning(ex, "Failed to delete branch {Branch} for worktree {Path}",
|
_logger.LogWarning(ex, "Failed to delete branch {Branch} for worktree {Path}",
|
||||||
row.BranchName, row.Path);
|
row.BranchName, row.Path);
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|||||||
|
|
||||||
namespace ClaudeDo.Worker.Worktrees;
|
namespace ClaudeDo.Worker.Worktrees;
|
||||||
|
|
||||||
|
/// <summary>One tracked worktree with its task/list context and on-disk facts.</summary>
|
||||||
|
/// <param name="IsGitWorktree">The path holds a git checkout (<c>.git</c> present), not just a directory.</param>
|
||||||
|
/// <param name="ContentInMain">Own commits are already in the local default branch (merged, fast-forwarded or squashed) — independent of <paramref name="State"/>.</param>
|
||||||
public sealed record WorktreeOverviewRow(
|
public sealed record WorktreeOverviewRow(
|
||||||
string TaskId,
|
string TaskId,
|
||||||
int TaskNumber,
|
int TaskNumber,
|
||||||
@@ -16,4 +19,6 @@ public sealed record WorktreeOverviewRow(
|
|||||||
WorktreeState State,
|
WorktreeState State,
|
||||||
string? DiffStat,
|
string? DiffStat,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
bool PathExistsOnDisk);
|
bool PathExistsOnDisk,
|
||||||
|
bool IsGitWorktree = false,
|
||||||
|
bool ContentInMain = false);
|
||||||
|
|||||||
@@ -489,4 +489,45 @@ public sealed class WorktreeMcpToolsTests : IDisposable
|
|||||||
WorktreeMcpTools.ProgressReportInterval = original;
|
WorktreeMcpTools.ProgressReportInterval = original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CleanupTaskWorktree_EmptyDirectory_RemovesDbRow()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
||||||
|
// Leave an empty, non-git directory behind where the worktree was.
|
||||||
|
GitRepoFixture.RunGit(list.WorkingDir!, "worktree", "remove", "--force", wt.WorktreePath);
|
||||||
|
Directory.CreateDirectory(wt.WorktreePath);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var rows = await sut.ListWorktrees(CancellationToken.None);
|
||||||
|
Assert.Equal("", rows.Single(r => r.TaskId == task.Id).HeadCommit);
|
||||||
|
|
||||||
|
var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Removed);
|
||||||
|
Assert.False(Directory.Exists(wt.WorktreePath));
|
||||||
|
using var ctx = _db.CreateContext();
|
||||||
|
Assert.Null(await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListWorktrees_SquashMergedBranch_ReportsMergedIntoMain()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.Done);
|
||||||
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "feature.txt"), "x");
|
||||||
|
GitRepoFixture.RunGit(wt.WorktreePath, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "feature");
|
||||||
|
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--squash", wt.BranchName);
|
||||||
|
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "squash");
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var row = (await sut.ListWorktrees(CancellationToken.None)).Single(r => r.TaskId == task.Id);
|
||||||
|
|
||||||
|
Assert.True(row.ContentInMain);
|
||||||
|
Assert.True(row.MergedIntoMain);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -531,4 +531,131 @@ public class WorktreeMaintenanceServiceTests : IDisposable
|
|||||||
Assert.Equal(1, progressCalls[0].Args[2]);
|
Assert.Equal(1, progressCalls[0].Args[2]);
|
||||||
Assert.Equal(2, progressCalls[1].Args[2]);
|
Assert.Equal(2, progressCalls[1].Args[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task SeedRowAsync(DbFixture db, ListEntity? list, TaskEntity task, string path, string branch,
|
||||||
|
string baseCommit, WorktreeState state = WorktreeState.Active)
|
||||||
|
{
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
if (list is not null) await new ListRepository(ctx).AddAsync(list);
|
||||||
|
await new TaskRepository(ctx).AddAsync(task);
|
||||||
|
await new WorktreeRepository(ctx).AddAsync(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = task.Id, Path = path, BranchName = branch,
|
||||||
|
BaseCommit = baseCommit, State = state, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CommitFile(string dir, string file)
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, file), file);
|
||||||
|
GitRepoFixture.RunGit(dir, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(dir, "commit", "-m", $"add {file}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SquashMerge(string repoDir, string branch)
|
||||||
|
{
|
||||||
|
GitRepoFixture.RunGit(repoDir, "merge", "--squash", branch);
|
||||||
|
GitRepoFixture.RunGit(repoDir, "commit", "-m", $"squash {branch}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetOverview_ContentInMain_True_For_SquashMerged_False_For_Unmerged_Commit()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var git = new GitService();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, squashed) = MakeEntities(repo.RepoDir);
|
||||||
|
var unmerged = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Done);
|
||||||
|
|
||||||
|
var squashedWt = await CreateWorktreeAsync(git, repo.RepoDir, squashed.Id);
|
||||||
|
var unmergedWt = await CreateWorktreeAsync(git, repo.RepoDir, unmerged.Id);
|
||||||
|
CommitFile(squashedWt, "squashed.txt");
|
||||||
|
CommitFile(unmergedWt, "unmerged.txt");
|
||||||
|
SquashMerge(repo.RepoDir, $"test/{squashed.Id}");
|
||||||
|
|
||||||
|
await SeedRowAsync(db, list, squashed, squashedWt, $"test/{squashed.Id}", repo.BaseCommit);
|
||||||
|
await SeedRowAsync(db, null, unmerged, unmergedWt, $"test/{unmerged.Id}", repo.BaseCommit);
|
||||||
|
|
||||||
|
var svc = new WorktreeMaintenanceService(db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
|
var rows = await svc.GetOverviewAsync(null);
|
||||||
|
|
||||||
|
Assert.True(rows.Single(r => r.TaskId == squashed.Id).ContentInMain);
|
||||||
|
Assert.False(rows.Single(r => r.TaskId == unmerged.Id).ContentInMain);
|
||||||
|
|
||||||
|
try { await git.WorktreeRemoveAsync(repo.RepoDir, squashedWt, force: true); } catch { }
|
||||||
|
try { await git.WorktreeRemoveAsync(repo.RepoDir, unmergedWt, force: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CleanupFinished_Removes_Active_Worktree_Squash_Merged_Outside_ClaudeDo()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var git = new GitService();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, squashed) = MakeEntities(repo.RepoDir);
|
||||||
|
var unmerged = MakeTaskForList(list.Id, ClaudeDo.Data.Models.TaskStatus.Done);
|
||||||
|
|
||||||
|
var squashedWt = await CreateWorktreeAsync(git, repo.RepoDir, squashed.Id);
|
||||||
|
var unmergedWt = await CreateWorktreeAsync(git, repo.RepoDir, unmerged.Id);
|
||||||
|
CommitFile(squashedWt, "squashed.txt");
|
||||||
|
CommitFile(unmergedWt, "unmerged.txt");
|
||||||
|
SquashMerge(repo.RepoDir, $"test/{squashed.Id}");
|
||||||
|
|
||||||
|
await SeedRowAsync(db, list, squashed, squashedWt, $"test/{squashed.Id}", repo.BaseCommit);
|
||||||
|
await SeedRowAsync(db, null, unmerged, unmergedWt, $"test/{unmerged.Id}", repo.BaseCommit);
|
||||||
|
|
||||||
|
var svc = new WorktreeMaintenanceService(db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
|
var result = await svc.CleanupFinishedAsync();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { squashed.Id }, result.RemovedTaskIds);
|
||||||
|
Assert.False(Directory.Exists(squashedWt));
|
||||||
|
Assert.True(Directory.Exists(unmergedWt));
|
||||||
|
|
||||||
|
try { await git.WorktreeRemoveAsync(repo.RepoDir, unmergedWt, force: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForceRemove_Prunes_And_Deletes_Branch_In_Owning_Repo_Not_List_WorkingDir()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var owner = NewRepo();
|
||||||
|
var other = NewRepo();
|
||||||
|
var git = new GitService();
|
||||||
|
var db = NewDb();
|
||||||
|
// The list points at a different repo than the one the worktree was created in.
|
||||||
|
var (list, task) = MakeEntities(other.RepoDir);
|
||||||
|
var wt = await CreateWorktreeAsync(git, owner.RepoDir, task.Id);
|
||||||
|
await SeedRowAsync(db, list, task, wt, $"test/{task.Id}", owner.BaseCommit);
|
||||||
|
|
||||||
|
var svc = new WorktreeMaintenanceService(db.CreateFactory(), git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
|
var result = await svc.ForceRemoveAsync(task.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Removed);
|
||||||
|
Assert.True(result.BranchDeleted);
|
||||||
|
Assert.DoesNotContain($"test/{task.Id}", GitRepoFixture.RunGit(owner.RepoDir, "branch", "--list"));
|
||||||
|
Assert.DoesNotContain(Path.GetFileName(wt), GitRepoFixture.RunGit(owner.RepoDir, "worktree", "list"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForceRemove_Reports_BranchDeleted_When_Branch_Was_Already_Gone()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = MakeEntities(repo.RepoDir);
|
||||||
|
var phantomPath = Path.Combine(Path.GetTempPath(), $"wt_phantom_{Guid.NewGuid():N}");
|
||||||
|
await SeedRowAsync(db, list, task, phantomPath, $"test/{task.Id}-never-existed", repo.BaseCommit);
|
||||||
|
|
||||||
|
var svc = new WorktreeMaintenanceService(db.CreateFactory(), new GitService(), NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
|
var result = await svc.ForceRemoveAsync(task.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Removed);
|
||||||
|
Assert.True(result.BranchDeleted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user