Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/App.JsonCodeGen.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public override void Write(Utf8JsonWriter writer, GridLength value, JsonSerializ
[JsonSerializable(typeof(Models.RepositorySettings))]
[JsonSerializable(typeof(Models.RepositoryUIStates))]
[JsonSerializable(typeof(Models.WtcraftSnapshot))]
[JsonSerializable(typeof(List<Models.WtcraftCliWorktree>))]
[JsonSerializable(typeof(List<Models.ConventionalCommitType>))]
[JsonSerializable(typeof(List<Models.LFSLock>))]
[JsonSerializable(typeof(List<Models.VisualStudioInstance>))]
Expand Down
92 changes: 92 additions & 0 deletions src/Models/CliWtcraftClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Diagnostics;
using System.Text.Json;
using System.Threading.Tasks;

namespace SourceGit.Models
{
/// <summary>
/// <see cref="IWtcraftClient"/> that shells out to the real <c>wtcraft</c> CLI
/// (<c>wtcraft status --json</c>, machine protocol v1) and maps its bare-array
/// output into a <see cref="WtcraftSnapshot"/>.
///
/// Any failure degrades to <c>null</c> so the panel falls back to Git-native
/// worktree facts: wtcraft missing, a build too old to support <c>--json</c>
/// (it prints a human table instead), a non-zero exit, or unparseable output.
///
/// Alarms and session state are not populated here — <c>status --json</c>
/// reports only task-contract and git facts. Reconciled alarms await
/// <c>observe --json</c>; session state lives in <c>.worktree-session.json</c>.
/// </summary>
public class CliWtcraftClient : IWtcraftClient
{
public async Task<WtcraftSnapshot> GetSnapshotAsync(string repoPath)
{
if (string.IsNullOrEmpty(repoPath))
return null;

try
{
var starter = new ProcessStartInfo
{
// Resolve `wtcraft` on PATH. Unix-first; on Windows this path
// is absent and Process.Start throws, caught below as a
// graceful null. A configurable binary path is a follow-up.
FileName = "/usr/bin/env",
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = repoPath,
};
starter.ArgumentList.Add("wtcraft");
starter.ArgumentList.Add("status");
starter.ArgumentList.Add("--json");
starter.ArgumentList.Add("--repo");
starter.ArgumentList.Add(repoPath);

using var proc = Process.Start(starter);
if (proc == null)
return null;

var stdout = await proc.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
await proc.WaitForExitAsync().ConfigureAwait(false);

if (proc.ExitCode != 0)
return null;

// Guard against an old wtcraft that ignores --json and prints a
// human table: real machine output is a JSON array.
var head = stdout?.TrimStart();
if (string.IsNullOrEmpty(head) || head[0] != '[')
return null;

var rows = JsonSerializer.Deserialize(stdout, JsonCodeGen.Default.ListWtcraftCliWorktree);
if (rows == null)
return null;

var snapshot = new WtcraftSnapshot { SchemaVersion = 1 };
foreach (var r in rows)
{
snapshot.Worktrees.Add(new WtcraftWorktreeState
{
Branch = r.Branch ?? string.Empty,
Path = r.Worktree ?? string.Empty,
Stage = r.Stage ?? string.Empty,
Role = r.Role ?? string.Empty,
Agent = r.Agent ?? string.Empty,
Verification = r.VerifyResult ?? string.Empty,
// Alarms / SessionState deferred: status --json carries neither.
});
}

return snapshot;
}
catch
{
// wtcraft absent, not executable, timed out, or output unparseable.
return null;
}
}
}
}
14 changes: 6 additions & 8 deletions src/Models/FixtureWtcraftClient.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
using System;
using System.Text.Json;
using System.Threading.Tasks;

using Avalonia.Platform;

namespace SourceGit.Models
{
/// <summary>
/// <see cref="IWtcraftClient"/> backed by a checked-in JSON fixture embedded
/// as an Avalonia resource. This keeps the panel demonstrable without
/// depending on an unreleased local wtcraft protocol, and exercises the same
/// graceful-degradation paths the real client will need.
/// as an Avalonia resource. Kept as a test/demo fallback; production wiring
/// uses <see cref="CliWtcraftClient"/>.
/// </summary>
public class FixtureWtcraftClient : IWtcraftClient
{
Expand All @@ -18,18 +18,16 @@ public class FixtureWtcraftClient : IWtcraftClient
public static readonly Uri FixtureUri =
new("avares://SourceGit/Resources/Fixtures/wtcraft-worktrees.json");

public bool IsAvailable => _snapshot != null;

public FixtureWtcraftClient()
{
_snapshot = TryLoad();
}

public WtcraftSnapshot GetSnapshot(string repoPath)
public Task<WtcraftSnapshot> GetSnapshotAsync(string repoPath)
{
// The fixture is repo-agnostic; a real client would key off repoPath.
// The fixture is repo-agnostic; a real client keys off repoPath.
_ = repoPath;
return _snapshot;
return Task.FromResult(_snapshot);
}

private static WtcraftSnapshot TryLoad()
Expand Down
25 changes: 11 additions & 14 deletions src/Models/IWtcraftClient.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
using System.Threading.Tasks;

namespace SourceGit.Models
{
/// <summary>
/// Abstraction over the wtcraft governance source.
///
/// The MVP only reads checked-in fixtures, but the panel is written against
/// this interface so the real (and still evolving) wtcraft machine protocol
/// can be wired in later without touching the UI. Implementations must never
/// throw from <see cref="GetSnapshot"/>; they return <c>null</c> to signal
/// "unavailable or unsupported" so callers can degrade to Git-native data.
/// The panel is written against this interface so the real (still evolving)
/// wtcraft machine protocol can be swapped in without touching the UI.
/// Implementations must never throw from <see cref="GetSnapshotAsync"/>; they
/// return <c>null</c> to signal "unavailable or unsupported" so callers can
/// degrade to Git-native data.
/// </summary>
public interface IWtcraftClient
{
/// <summary>
/// True when a usable snapshot could be loaded. When false, the panel
/// shows Git-native worktree facts only.
/// </summary>
bool IsAvailable { get; }

/// <summary>
/// Returns governance state for the given repository, or <c>null</c>
/// when wtcraft is unavailable or its schema is unsupported.
/// Returns governance state for the given repository, or <c>null</c> when
/// wtcraft is unavailable or its output is unsupported. Runs off the UI
/// thread; implementations may shell out to the wtcraft CLI.
/// </summary>
WtcraftSnapshot GetSnapshot(string repoPath);
Task<WtcraftSnapshot> GetSnapshotAsync(string repoPath);
}
}
31 changes: 31 additions & 0 deletions src/Models/WtcraftState.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace SourceGit.Models
{
Expand Down Expand Up @@ -35,4 +36,34 @@ public class WtcraftSnapshot
public int SchemaVersion { get; set; } = 0;
public List<WtcraftWorktreeState> Worktrees { get; set; } = [];
}

/// <summary>
/// One worktree row from `wtcraft status --json` (machine protocol v1). This
/// is the raw wire shape — the command emits a bare JSON array of these — with
/// snake_case field names. <see cref="CliWtcraftClient"/> maps it into
/// <see cref="WtcraftWorktreeState"/>.
///
/// Alarms and session state are intentionally absent: status --json reports
/// only task-contract + git facts. Reconciled alarms await `observe --json`
/// and per-worktree session state lives in `.worktree-session.json`.
/// </summary>
public class WtcraftCliWorktree
{
[JsonPropertyName("repo_root")] public string RepoRoot { get; set; }
[JsonPropertyName("worktree")] public string Worktree { get; set; }
[JsonPropertyName("branch")] public string Branch { get; set; }
[JsonPropertyName("zombie")] public bool Zombie { get; set; }
[JsonPropertyName("locked")] public bool Locked { get; set; }
[JsonPropertyName("contracted")] public bool Contracted { get; set; }
[JsonPropertyName("task_file")] public string TaskFile { get; set; }
[JsonPropertyName("stage")] public string Stage { get; set; }
[JsonPropertyName("role")] public string Role { get; set; }
[JsonPropertyName("agent")] public string Agent { get; set; }
[JsonPropertyName("status")] public string Status { get; set; }
[JsonPropertyName("priority")] public string Priority { get; set; }
[JsonPropertyName("created")] public string Created { get; set; }
[JsonPropertyName("base")] public string Base { get; set; }
[JsonPropertyName("verify_result")] public string VerifyResult { get; set; }
[JsonPropertyName("verified")] public string Verified { get; set; }
}
}
5 changes: 3 additions & 2 deletions src/ViewModels/Repository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ public Repository(bool isBare, string path, string gitDir)

_settings = Models.RepositorySettings.Get(_gitCommonDir);
_uiStates = Models.RepositoryUIStates.Load(GitDir);
_worktreesPanel = new WorktreesPanel(new Models.FixtureWtcraftClient(), this);
_worktreesPanel = new WorktreesPanel(new Models.CliWtcraftClient(), this);
}

public void Open()
Expand Down Expand Up @@ -1182,10 +1182,11 @@ public void RefreshWorktrees()
{
var worktrees = await new Commands.Worktree(FullPath).ReadAllAsync().ConfigureAwait(false);
var cleaned = Worktree.Build(FullPath, worktrees);
var snapshot = await _worktreesPanel.FetchSnapshotAsync(FullPath).ConfigureAwait(false);
Dispatcher.UIThread.Invoke(() =>
{
Worktrees = cleaned;
_worktreesPanel.Update(FullPath, cleaned);
_worktreesPanel.Update(snapshot, cleaned);
});
});
}
Expand Down
34 changes: 20 additions & 14 deletions src/ViewModels/WorktreesPanel.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;

using CommunityToolkit.Mvvm.ComponentModel;

Expand Down Expand Up @@ -74,9 +75,26 @@ public WorktreesPanel(Models.IWtcraftClient wtcraft, Repository repo)
_repo = repo;
}

public void Update(string repoPath, IReadOnlyList<Worktree> worktrees)
// Runs off the UI thread: may shell out to the wtcraft CLI. Never throws.
public async Task<Models.WtcraftSnapshot> FetchSnapshotAsync(string repoPath)
{
if (_wtcraft == null)
return null;

try
{
return await _wtcraft.GetSnapshotAsync(repoPath).ConfigureAwait(false);
}
catch
{
return null;
}
}

// Applies an already-fetched snapshot. Must run on the UI thread (mutates
// the observable Items collection).
public void Update(Models.WtcraftSnapshot snapshot, IReadOnlyList<Worktree> worktrees)
{
var snapshot = TryGetSnapshot(repoPath);
IsWtcraftAvailable = snapshot != null;

Items.Clear();
Expand All @@ -89,18 +107,6 @@ public void Update(string repoPath, IReadOnlyList<Worktree> worktrees)
IsEmpty = Items.Count == 0;
}

private Models.WtcraftSnapshot TryGetSnapshot(string repoPath)
{
try
{
return _wtcraft?.GetSnapshot(repoPath);
}
catch
{
return null;
}
}

private static Models.WtcraftWorktreeState MatchState(Models.WtcraftSnapshot snapshot, Worktree wt)
{
if (snapshot?.Worktrees == null)
Expand Down