Skip to content
Merged
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
65 changes: 65 additions & 0 deletions src/Glyph/Commands/UpdateCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.CommandLine;
using Glyph.Services;
using Spectre.Console;

namespace Glyph.Commands;

public static class UpdateCommand
{
public static Command Create()
{
var command = new Command("update") { Description = "Update Glyph to the latest version" };

command.SetAction(async (ParseResult parseResult, CancellationToken ct) =>
{
var current = UpdateChecker.GetCurrentVersion();

await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.StartAsync("Checking for updates...", async ctx =>
{
string? latest;
try
{
latest = await UpdateChecker.GetLatestVersionAsync();
}
catch
{
AnsiConsole.MarkupLine("[red]Failed to check for updates. Please check your internet connection.[/]");
return;
}

if (latest == null)
{
AnsiConsole.MarkupLine("[yellow]Could not determine the latest version.[/]");
return;
}

if (!UpdateChecker.IsNewerVersion(latest, current))
{
AnsiConsole.MarkupLine($"[green]Glyph is already up to date ({current}).[/]");
return;
}

AnsiConsole.MarkupLine($"Updating Glyph from {current} to {latest}...");
ctx.Status("Updating...");

var (exitCode, output, error) = await ProcessRunner.RunAsync(
"dotnet", "tool update --global Glyph");

if (exitCode == 0)
{
AnsiConsole.MarkupLine($"[green]Successfully updated Glyph to {latest}![/]");
}
else
{
AnsiConsole.MarkupLine("[red]Update failed.[/]");
if (!string.IsNullOrEmpty(error))
AnsiConsole.WriteLine(error);
}
});
});

return command;
}
}
21 changes: 19 additions & 2 deletions src/Glyph/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@
using Glyph.Services;
using Spectre.Console;

if (!GitService.IsGitRepository())
// Allow 'update' to run outside a git repository
var isUpdateCommand = args.Length > 0 && args[0] == "update";

if (!isUpdateCommand && !GitService.IsGitRepository())
{
AnsiConsole.MarkupLine("[red]Error:[/] Not a git repository (or any parent up to mount point).");
return 1;
}

// Start the update check in the background (non-blocking)
var updateCheckTask = UpdateChecker.CheckForUpdateAsync();

var rootCommand = new RootCommand("Glyph - A Git TUI for trunk-based development workflows");
rootCommand.Subcommands.Add(TreeCommand.Create());
rootCommand.Subcommands.Add(ParentCommand.Create());
Expand All @@ -20,11 +26,22 @@
rootCommand.Subcommands.Add(CommitCommand.Create());
rootCommand.Subcommands.Add(EditCommand.Create());
rootCommand.Subcommands.Add(ShipCommand.Create());
rootCommand.Subcommands.Add(UpdateCommand.Create());

// Default to tree view when no subcommand is given
rootCommand.SetAction(parseResult =>
{
TreeCommand.Create().Parse(Array.Empty<string>()).Invoke();
});

return rootCommand.Parse(args).Invoke();
var result = rootCommand.Parse(args).Invoke();

// Show update notification if available (don't delay if check hasn't finished)
if (updateCheckTask.IsCompleted)
{
var updateMessage = await updateCheckTask;
if (updateMessage != null)
AnsiConsole.MarkupLine(updateMessage);
}

return result;
128 changes: 128 additions & 0 deletions src/Glyph/Services/UpdateChecker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using System.Net.Http.Json;
using System.Reflection;
using System.Text.Json.Serialization;

namespace Glyph.Services;

[JsonSerializable(typeof(UpdateChecker.NuGetVersionIndex))]
internal partial class UpdateCheckerJsonContext : JsonSerializerContext;

public static class UpdateChecker
{
private const string NuGetIndexUrl = "https://api.nuget.org/v3-flatcontainer/glyph/index.json";
private static readonly string CacheDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".glyph");
private static readonly string CacheFile = Path.Combine(CacheDir, "update-check");

public static string GetCurrentVersion()
{
var version = typeof(UpdateChecker).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;

// Strip any +metadata suffix (e.g. "0.1.0+abc123")
if (version != null)
{
var plusIndex = version.IndexOf('+');
if (plusIndex >= 0)
version = version[..plusIndex];
}

return version ?? "0.0.0";
}

public static async Task<string?> GetLatestVersionAsync()
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
http.DefaultRequestHeaders.UserAgent.ParseAdd("Glyph-UpdateChecker/1.0");

var response = await http.GetFromJsonAsync(NuGetIndexUrl, UpdateCheckerJsonContext.Default.NuGetVersionIndex);
if (response?.Versions is not { Count: > 0 })
return null;

// Versions are listed oldest to newest; take the last non-prerelease version
for (var i = response.Versions.Count - 1; i >= 0; i--)
{
var v = response.Versions[i];
if (!v.Contains('-'))
return v;
}

return response.Versions[^1];
}

public static bool IsNewerVersion(string latest, string current)
{
return Version.TryParse(NormalizeVersion(latest), out var latestVer)
&& Version.TryParse(NormalizeVersion(current), out var currentVer)
&& latestVer > currentVer;
}

/// <summary>
/// Checks for updates at most once per day. Returns a message if an update is
/// available, or null if the version is current (or the check was skipped/failed).
/// </summary>
public static async Task<string?> CheckForUpdateAsync()
{
try
{
if (!ShouldCheck())
return null;

var latest = await GetLatestVersionAsync();
WriteCacheTimestamp();

if (latest == null)
return null;

var current = GetCurrentVersion();
if (IsNewerVersion(latest, current))
return $"[yellow]A new version of Glyph is available: {latest} (current: {current}). Run [bold]glyph update[/] to update.[/]";

return null;
}
catch
{
// Never let update checks break the main flow
return null;
}
}

private static bool ShouldCheck()
{
if (!File.Exists(CacheFile))
return true;

var lastCheck = File.GetLastWriteTimeUtc(CacheFile);
return DateTime.UtcNow - lastCheck > TimeSpan.FromHours(24);
}

private static void WriteCacheTimestamp()
{
try
{
Directory.CreateDirectory(CacheDir);
File.WriteAllText(CacheFile, DateTime.UtcNow.ToString("O"));
}
catch
{
// Ignore cache write failures
}
}

private static string NormalizeVersion(string version)
{
// Ensure we have at least major.minor for Version.TryParse
var parts = version.Split('.');
return parts.Length switch
{
1 => $"{parts[0]}.0",
_ => version
};
}

internal sealed class NuGetVersionIndex
{
[JsonPropertyName("versions")]
public List<string> Versions { get; set; } = [];
}
}