fix(log): show control characters from repository content as visible text #144

Merged
james.maes merged 2 commits from fix/143-visible-control-characters into main 2026-11-10 20:01:00 +00:00
18 changed files with 928 additions and 45 deletions

No files matched your search

+2
View File
@@ -382,6 +382,8 @@ failures, and path-sanitization notes — the same log the desktop app shows
under **View > Activity log**. Tokens are never written to the log, or anywhere
else on disk; see [SECURITY.md](../SECURITY.md).
Each entry is one line that starts with its timestamp. Text that came from a repository, such as a file path, is written with control characters shown as `\uXXXX`, and the lines of an exception are indented under their entry, so nothing in a repository can add or imitate an entry. Everything the CLI prints as text shows such characters the same way, and on a console whose code page cannot represent a character (code page 437 or 850, for example) that character is shown in the same form too; the JSON modes use JSON's own escaping.
## Exit codes
| Code | Meaning | What to do |
+2
View File
@@ -96,6 +96,7 @@ flowchart LR
| T11 | Release pipeline | Tampering | Compromised action or cache poisons a release | Low | Critical | Build runs in the standards' shared release workflow (SLSA Build L3 provenance, `--signer-repo KofTwentyTwo/standards`), SHA-pinned actions, no caches, read-only tokens, publishing tokens only in the tag-restricted `release` environment, `protect-release-tags`, required gates re-run on the tag | Mitigated |
| T12 | Repository | Repudiation | Unattributed change reaches `main` | Low | High | Signed, signed-off commits; squash-only through gated pull requests; no bypass actors | Mitigated |
| T14 | PAT handling | Information disclosure | The token is offered to a host other than the one the repository was listed on: an existing folder whose `origin` points elsewhere, or a server named by the repository's own configuration during the WSL clone (for example an LFS endpoint) | Low | Critical | The git credential callback answers only a remote whose URL is HTTPS on the listed URL's host and port, and refuses every other remote with a clear error (a redirect that the listed host itself issues is followed by libgit2; that host already holds the token); a folder whose origin names another repository fails before any request; the WSL credential helper is scoped to the listed host (`credential.https://<host>.helper`) and an existing WSL destination is pulled only when its stored origin is the listed URL (#139) | Mitigated |
| T15 | Logs and terminal | Spoofing / Tampering | Text from a repository or a server (a file path may contain a newline, an escape sequence, or a character that reorders or hides text) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` shows control, separator and format characters (direction marks and overrides, invisible characters, tag characters) and unpaired surrogates as visible `\uXXXX` text: every log message is one line, and an exception's lines are indented under their entry so none can start like one. The CLI writes every line through one place, `Terminal`, which also shows any character the console's code page cannot represent in that form, because a legacy code page turns some printable characters into control bytes; a test fails if any other CLI file writes to the console. The JSON modes rely on the JSON encoder (#143) | Mitigated for the log and the CLI. Not covered: the desktop app's own surfaces (row error text, the path-recovery dialog), where a newline is harmless but a direction override in a path the user is asked to approve is not; and text that merely looks like something else (look-alike letters, an escape written out literally) |
| T13 | CLI / data directory | Tampering | A malicious `accounts.json` or `settings.json` redirects targets | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained | Accepted (same-user boundary) |
## 7. Accepted risks
@@ -118,3 +119,4 @@ flowchart LR
| 2026-10-10 | v1.1.0 | James Maes (@KofTwentyTwo) | T5: accounts can reference the default token (`TokenSource = Default`) instead of holding a copy; one `AccountTokenResolver` serves the app and the CLI, so rotating the default token in Settings rotates every such account (#102, docs/plans/default-token-ux.md) |
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T14 added, T4/T8/T9 updated: an existing folder is fetched only when its origin names the listed repository, through the listed URL, and the folder's own `url.*.insteadOf` and (for a non-HTTPS fetch) `core.sshCommand` are refused; the git credential callback and the WSL credential helper answer only for the listed host over HTTPS; WSL organization and repository names validated as single names (#139) |
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T3/T4 updated: path recovery writes and deletes only through `WorkingTree` (inside the working directory, never in `.git`, never through a link); `.git` and its Windows aliases are invalid path segments; the stored recovery file's paths are validated on load; cleanup of a failed clone does not follow links (#141) |
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T15 added: text from repositories and servers is shown with control, separator and format characters made visible, in the activity log and in everything the CLI prints as text, including characters a legacy console code page would turn into control bytes; the desktop app's surfaces are named as not covered (#143) |
+15
View File
@@ -65,6 +65,21 @@ public sealed class OrgsCommandTests
[Fact]
public async Task Plain_ALoginFromTheServer_ReachesTheTerminalInVisibleForm()
{
using var console = new ConsoleCapture();
using IDisposable _ = Token("ghp_x");
var lister = new FakeOrgLister { Result = ["ac" + (char)0x1B + "[2Jme"] };
int code = await Run(lister, "--token-env", EnvVar);
Assert.Equal(0, code);
Assert.Equal("ac" + "\\" + "u001B[2Jme", console.Out.TrimEnd());
}
[Fact]
public async Task Json_PrintsAnArray()
{
+38
View File
@@ -39,6 +39,38 @@ public sealed class ProgressPrinterTests
[Theory]
[InlineData(false)]
[InlineData(true)]
public void TextModes_ShowControlCharactersFromRepositoryContentAsVisibleText(bool quiet)
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(quiet ? OutputMode.Quiet : OutputMode.Text);
string escape = ((char)0x1B).ToString();
// An error that quotes a path from a repository's tree: a newline that would
// start a fake line, and an escape sequence the terminal would obey.
string error = "invalid path 'a\nother Done" + escape + "[2J'";
printer.Report(new RepoProgress("we" + escape + "[31mird", SyncStatus.Cloning));
printer.Report(new RepoProgress("repo", SyncStatus.Failed, error));
string line = Assert.Single(Lines(console.Error));
Assert.StartsWith("repo Failed invalid path 'a", line, StringComparison.Ordinal);
Assert.Contains(Visible(0x0A) + "other Done", line);
Assert.Contains(Visible(0x1B) + "[2J'", line);
// Compared as characters: a culture-aware text search treats the escape character as ignorable.
Assert.DoesNotContain((char)0x1B, console.Error);
Assert.DoesNotContain((char)0x1B, console.Out);
if(!quiet)
{
Assert.Equal("we" + Visible(0x1B) + "[31mird Cloning", Assert.Single(Lines(console.Out)));
}
// The record for the summary document keeps the text as it was: JSON has its own escaping.
Assert.Equal(error, Assert.Single(printer.Failures).Error);
}
[Fact]
public void Quiet_PrintsOnlyFailures()
{
@@ -116,6 +148,12 @@ public sealed class ProgressPrinterTests
/// <summary>The visible form of one UTF-16 code unit: a backslash, "u", four upper-case hex digits.</summary>
private static string Visible(int unit)
=> "\\" + "u" + unit.ToString("X4", System.Globalization.CultureInfo.InvariantCulture);
private static string[] Lines(string text)
=> text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd('\r')).ToArray();
}
+23
View File
@@ -83,6 +83,29 @@ public sealed class ReposCommandTests
[Fact]
public async Task Plain_NamesAndBranchesFromTheServer_ReachTheTerminalInVisibleForm()
{
using var console = new ConsoleCapture();
using IDisposable _ = Token("ghp_x");
string escape = ((char)0x1B).ToString();
var lister = new FakeRepoLister { Result = [Repo("api" + escape + "[2J", "ma" + (char)0x0A + "in")] };
int code = await Run(lister, "--org", "acme", "--token-env", EnvVar);
Assert.Equal(0, code);
Assert.DoesNotContain((char)0x1B, console.Out);
Assert.Equal("api" + Visible(0x1B) + "[2J ma" + Visible(0x0A) + "in", console.Out.TrimEnd());
}
/// <summary>The visible form of one UTF-16 code unit: a backslash, "u", four upper-case hex digits.</summary>
private static string Visible(int unit)
=> "\\" + "u" + unit.ToString("X4", System.Globalization.CultureInfo.InvariantCulture);
[Fact]
public async Task Filters_NarrowTheListing_AndAnEmptyResultSaysWhy()
{
+240
View File
@@ -0,0 +1,240 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text;
using System.Text.RegularExpressions;
using gclo.Engine;
namespace gclo.Cli.Tests;
/// <summary>
/// <see cref="Terminal"/> is the one place the CLI writes to the console, and what it
/// writes is safe for the terminal in the console's own encoding (#143).
/// </summary>
public sealed partial class TerminalTests
{
private const char Escape = (char)0x1B;
static TerminalTests()
{
// Legacy code pages are in the framework but not registered by default.
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
// ---------------------------------------------------------------- Unicode consoles
[Theory]
[InlineData(65001)] // UTF-8
[InlineData(1200)] // UTF-16
[InlineData(12000)] // UTF-32
public void Safe_OnAUnicodeConsole_IsTheVisibleFormAndNothingMore(int codePage)
{
var encoding = Encoding.GetEncoding(codePage);
string text = "caf" + (char)0xE9 + " " + (char)0x65E5 + (char)0x672C + " " + (char)0x2190 + " plain";
Assert.Same(text, Terminal.Safe(text, encoding));
Assert.Equal(DisplayText.Escape("a\nb" + Escape + "[2J"), Terminal.Safe("a\nb" + Escape + "[2J", encoding));
Assert.Equal("", Terminal.Safe(null, encoding));
}
[Fact]
public void Safe_NullEncoding_IsACallerError()
{
Assert.Throws<ArgumentNullException>(() => Terminal.Safe("text", null!));
}
// ---------------------------------------------------------------- legacy code pages
[Theory]
[InlineData(437)]
[InlineData(850)]
public void Safe_OnALegacyCodePage_ACharacterItWouldTurnIntoAControlByte_IsShownVisibly(int codePage)
{
var encoding = Encoding.GetEncoding(codePage);
// Printable characters that are not control characters, so the first pass leaves
// them alone; code page 437 "best-fits" the arrow to ESC and the inverse circle
// to a line feed.
string hostile = "x:" + (char)0x2190 + "[2J" + (char)0x25D9 + "other Done";
Assert.Same(hostile, DisplayText.Escape(hostile));
string safe = Terminal.Safe(hostile, encoding);
Assert.Equal("x:" + Visible(0x2190) + "[2J" + Visible(0x25D9) + "other Done", safe);
AssertNoControlBytes(encoding.GetBytes(safe));
}
[Theory]
[InlineData(437)]
[InlineData(850)]
[InlineData(1252)]
[InlineData(932)]
public void Safe_OnALegacyCodePage_NoCharacterOfTheBasicPlane_ReachesTheTerminalAsAControlByte(int codePage)
{
var encoding = Encoding.GetEncoding(codePage);
var failures = new List<string>();
for(int code = 0; code <= 0xFFFF; code++)
{
if(code is >= 0xD800 and <= 0xDFFF)
{
continue; // a lone surrogate is covered below
}
byte[] bytes = encoding.GetBytes(Terminal.Safe("a" + (char)code + "b", encoding));
if(bytes.Any(b => b < 0x20 || b == 0x7F))
{
failures.Add(code.ToString("X4", System.Globalization.CultureInfo.InvariantCulture));
}
}
Assert.True(failures.Count == 0, "control bytes for: " + string.Join(' ', failures.Take(40)));
}
[Theory]
[InlineData(437)]
[InlineData(1252)]
public void Safe_OnALegacyCodePage_KeepsWhatTheCodePageCanShow_AndEscapesWhatItCannot(int codePage)
{
var encoding = Encoding.GetEncoding(codePage);
string emoji = char.ConvertFromUtf32(0x1F600);
// e-acute is in both code pages; the kanji and the emoji are in neither.
string safe = Terminal.Safe("caf" + (char)0xE9 + " " + (char)0x65E5 + " " + emoji + " end", encoding);
Assert.Equal("caf" + (char)0xE9 + " " + Visible(0x65E5) + " " + Visible(emoji[0]) + Visible(emoji[1]) + " end", safe);
}
[Fact]
public void Safe_IsStable_WhenAppliedTwice()
{
var encoding = Encoding.GetEncoding(437);
string once = Terminal.Safe("a\nb" + (char)0x2190 + (char)0x65E5, encoding);
Assert.Equal(once, Terminal.Safe(once, encoding));
}
// ---------------------------------------------------------------- the writers
[Fact]
public void OutAndError_WriteOneSafeLine_ToTheirOwnStream()
{
using var console = new ConsoleCapture();
Terminal.Out("repo" + Escape + "[31m Done\nforged Done");
Terminal.Error("repo Failed bad" + Escape + "[2J\nforged");
Assert.Equal("repo" + Visible(0x1B) + "[31m Done" + Visible(0x0A) + "forged Done" + Environment.NewLine, console.Out);
Assert.Equal("repo Failed bad" + Visible(0x1B) + "[2J" + Visible(0x0A) + "forged" + Environment.NewLine, console.Error);
}
[Fact]
public void OutVerbatim_IsForFixedTextAndJson_AndChangesNothing()
{
using var console = new ConsoleCapture();
Terminal.OutVerbatim("Usage:\n gclo sync");
Assert.Equal("Usage:\n gclo sync" + Environment.NewLine, console.Out);
Assert.Equal("", console.Error);
}
[Fact]
public void ErrorPrompt_StaysOnItsLine_UntilTheLineBreak()
{
using var console = new ConsoleCapture();
Terminal.ErrorPrompt("Token: ");
Assert.Equal("Token: ", console.Error);
Terminal.ErrorLineBreak();
Assert.Equal("Token: " + Environment.NewLine, console.Error);
Assert.Equal("", console.Out);
}
// ---------------------------------------------------------------- nothing bypasses it
[Fact]
public void NoOtherFileOfTheCli_WritesToTheConsoleDirectly()
{
// Every line the CLI prints goes through Terminal, so a new message cannot
// forget to be made safe. The check is on the source: an output that bypasses
// Terminal would otherwise pass every behavioural test that does not happen
// to feed it hostile text.
string cli = Path.Combine(RepoRoot, "gclo.Cli");
var offenders = new List<string>();
foreach(string file in Directory.EnumerateFiles(cli, "*.cs", SearchOption.TopDirectoryOnly))
{
if(string.Equals(Path.GetFileName(file), "Terminal.cs", StringComparison.Ordinal))
{
continue;
}
string[] lines = File.ReadAllLines(file);
for(int i = 0; i < lines.Length; i++)
{
if(DirectConsoleWrite().IsMatch(lines[i]))
{
offenders.Add($"{Path.GetFileName(file)}:{i + 1}: {lines[i].Trim()}");
}
}
}
Assert.True(offenders.Count == 0, "write through Terminal instead:" + Environment.NewLine + string.Join(Environment.NewLine, offenders));
}
// ---------------------------------------------------------------- helpers
private static string RepoRoot
{
get
{
DirectoryInfo? current = new(AppContext.BaseDirectory);
while(current is not null && !File.Exists(Path.Combine(current.FullName, "gclo.slnx")))
{
current = current.Parent;
}
return current?.FullName ?? throw new FileNotFoundException("gclo.slnx not found above the test directory");
}
}
private static void AssertNoControlBytes(byte[] bytes)
=> Assert.DoesNotContain(bytes, b => b < 0x20 || b == 0x7F);
/// <summary>The visible form of one UTF-16 code unit: a backslash, "u", four upper-case hex digits.</summary>
private static string Visible(int unit)
=> "\\" + "u" + unit.ToString("X4", System.Globalization.CultureInfo.InvariantCulture);
[GeneratedRegex(@"\bConsole\s*\.\s*(Out|Error)\b|\bConsole\s*\.\s*Write(Line)?\s*\(", RegexOptions.ExplicitCapture, 1000)]
private static partial Regex DirectConsoleWrite();
}
+10 -10
View File
@@ -122,7 +122,7 @@ internal static class AccountsCommand
switch(reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
Terminal.OutVerbatim(HelpText);
return ExitCodes.Success;
case "--json":
reader.RejectValue();
@@ -148,7 +148,7 @@ internal static class AccountsCommand
a.Id.ToString("N"), a.Name, a.Description, a.Organization, a.TargetRoot,
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary, TokenSourceLabel(a)))
.ToList();
Console.Out.WriteLine(JsonSerializer.Serialize(
Terminal.OutVerbatim(JsonSerializer.Serialize(
summaries, CliJsonContext.Default.IReadOnlyListAccountSummary));
return ExitCodes.Success;
}
@@ -156,7 +156,7 @@ internal static class AccountsCommand
if(accounts.Count == 0)
{
// Diagnostics go to stderr so redirected stdout stays clean (and empty).
Console.Error.WriteLine("No accounts yet. Create one with 'gclo accounts add' or in the gclo desktop app.");
Terminal.Error("No accounts yet. Create one with 'gclo accounts add' or in the gclo desktop app.");
return ExitCodes.Success;
}
@@ -165,7 +165,7 @@ internal static class AccountsCommand
int targetWidth = accounts.Max(a => a.TargetRoot.Length);
foreach(Account account in accounts)
{
Console.Out.WriteLine(
Terminal.Out(
$"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} "
+ $"{account.TargetRoot.PadRight(targetWidth)} {TokenSourceLabel(account).PadRight(7)} {FormatLastSync(account.LastSyncUtc)}");
}
@@ -204,7 +204,7 @@ internal static class AccountsCommand
switch(reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
Terminal.OutVerbatim(HelpText);
return ExitCodes.Success;
case "--name":
name = reader.RequireValue();
@@ -263,7 +263,7 @@ internal static class AccountsCommand
TokenSource = tokenOptions.UseDefault ? TokenSource.Default : TokenSource.Own,
};
SaveOrFail(store, account, token);
Console.Out.WriteLine($"Account '{account.Name}' added" + (tokenOptions.UseDefault ? " (uses the default token)." : "."));
Terminal.Out($"Account '{account.Name}' added" + (tokenOptions.UseDefault ? " (uses the default token)." : "."));
return ExitCodes.Success;
}
catch(Exception ex)
@@ -298,7 +298,7 @@ internal static class AccountsCommand
switch(reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
Terminal.OutVerbatim(HelpText);
return ExitCodes.Success;
case "--name":
name = reader.RequireValue();
@@ -370,7 +370,7 @@ internal static class AccountsCommand
string tokenNote = tokenOptions.UseDefault
? " (now uses the default token)."
: token is null ? "." : " (token replaced).";
Console.Out.WriteLine($"Account '{updated.Name}' updated" + tokenNote);
Terminal.Out($"Account '{updated.Name}' updated" + tokenNote);
return ExitCodes.Success;
}
catch(Exception ex)
@@ -394,7 +394,7 @@ internal static class AccountsCommand
switch(reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
Terminal.OutVerbatim(HelpText);
return ExitCodes.Success;
case "--name":
name = reader.RequireValue();
@@ -415,7 +415,7 @@ internal static class AccountsCommand
(AccountsStore store, _) = open(log);
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
store.Delete(existing.Id); // the store logs the deletion
Console.Out.WriteLine($"Account '{existing.Name}' removed.");
Terminal.Out($"Account '{existing.Name}' removed.");
return ExitCodes.Success;
}
catch(Exception ex)
+3 -3
View File
@@ -68,7 +68,7 @@ internal static class OrgsCommand
switch(reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
Terminal.OutVerbatim(HelpText);
return ExitCodes.Success;
case "--json":
reader.RejectValue();
@@ -103,13 +103,13 @@ internal static class OrgsCommand
if(json)
{
Console.Out.WriteLine(JsonSerializer.Serialize(logins, CliJsonContext.Default.IReadOnlyListString));
Terminal.OutVerbatim(JsonSerializer.Serialize(logins, CliJsonContext.Default.IReadOnlyListString));
}
else
{
foreach(string login in logins)
{
Console.Out.WriteLine(login);
Terminal.Out(login);
}
}
return ExitCodes.Success;
+11 -11
View File
@@ -77,7 +77,7 @@ internal static class Program
// repositories are marked Canceled, and the summary still prints.
e.Cancel = true;
cts.Cancel();
Console.Error.WriteLine("Canceling... (press Ctrl+C again to abort immediately)");
Terminal.Error("Canceling... (press Ctrl+C again to abort immediately)");
}
// Second Ctrl+C: e.Cancel stays false and the process terminates.
};
@@ -88,18 +88,18 @@ internal static class Program
}
catch(CliUsageException ex)
{
Console.Error.WriteLine(ex.Message);
Console.Error.WriteLine("Run 'gclo --help' for usage.");
Terminal.Error(ex.Message);
Terminal.Error("Run 'gclo --help' for usage.");
return ExitCodes.Fatal;
}
catch(CliErrorException ex)
{
Console.Error.WriteLine(ex.Message);
Terminal.Error(ex.Message);
return ex.ExitCode;
}
catch(OperationCanceledException) when(cts.IsCancellationRequested)
{
Console.Error.WriteLine("Canceled.");
Terminal.Error("Canceled.");
return ExitCodes.Partial;
}
catch(Exception ex)
@@ -129,12 +129,12 @@ internal static class Program
// The log must never make a bad situation worse.
}
Console.Error.WriteLine($"Unexpected error ({ex.GetType().Name}): {ex.Message}");
Terminal.Error($"Unexpected error ({ex.GetType().Name}): {ex.Message}");
if(details.Length > 0)
{
Console.Error.WriteLine($"Details: {details}");
Terminal.Error($"Details: {details}");
}
Console.Error.WriteLine("This looks like a bug in gclo; please report it at https://github.com/KofTwentyTwo/gclo/issues.");
Terminal.Error("This looks like a bug in gclo; please report it at https://github.com/KofTwentyTwo/gclo/issues.");
return ExitCodes.Unexpected;
}
@@ -160,12 +160,12 @@ internal static class Program
case "accounts":
return Task.FromResult(AccountsCommand.Run(rest));
case "--help" or "-h":
Console.Out.WriteLine(RootHelp);
Terminal.OutVerbatim(RootHelp);
return Task.FromResult(ExitCodes.Success);
case "help":
if(rest.Length == 0)
{
Console.Out.WriteLine(RootHelp);
Terminal.OutVerbatim(RootHelp);
return Task.FromResult(ExitCodes.Success);
}
// 'gclo help sync' means 'gclo sync --help'; an unknown name is a usage error.
@@ -173,7 +173,7 @@ internal static class Program
? RunAsync([rest[0], "--help"], cancellationToken)
: throw new CliUsageException($"Unknown command '{rest[0]}'.");
case "--version":
Console.Out.WriteLine(Version());
Terminal.OutVerbatim(Version());
return Task.FromResult(ExitCodes.Success);
default:
throw new CliUsageException($"Unknown command '{command}'.");
+3 -3
View File
@@ -94,7 +94,7 @@ internal sealed class ProgressPrinter : IProgress<RepoProgress>
switch(_mode)
{
case OutputMode.Text or OutputMode.Quiet:
Console.Error.WriteLine($"{failure.Repo} Failed {failure.Error}");
Terminal.Error($"{failure.Repo} Failed {failure.Error}");
break;
case OutputMode.JsonLines:
WriteProgressLine(value.RepoName, value.Status, failure.Error);
@@ -108,7 +108,7 @@ internal sealed class ProgressPrinter : IProgress<RepoProgress>
switch(_mode)
{
case OutputMode.Text:
Console.Out.WriteLine($"{value.RepoName} {value.Status}");
Terminal.Out($"{value.RepoName} {value.Status}");
break;
case OutputMode.JsonLines:
WriteProgressLine(value.RepoName, value.Status, null);
@@ -122,7 +122,7 @@ internal sealed class ProgressPrinter : IProgress<RepoProgress>
private static void WriteProgressLine(string repo, SyncStatus status, string? error)
=> Console.Out.WriteLine(JsonSerializer.Serialize(
=> Terminal.OutVerbatim(JsonSerializer.Serialize(
new SyncProgressLine("progress", repo, status.ToString(), error), CliJsonContext.Default.SyncProgressLine));
+4 -4
View File
@@ -73,7 +73,7 @@ internal static class ReposCommand
switch(reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
Terminal.OutVerbatim(HelpText);
return ExitCodes.Success;
case "--org":
org = reader.RequireValue();
@@ -132,13 +132,13 @@ internal static class ReposCommand
IReadOnlyList<RepoSummary> summaries = repositories
.Select(r => new RepoSummary(r.Name, r.DefaultBranch, r.IsArchived, r.CloneUrl))
.ToList();
Console.Out.WriteLine(JsonSerializer.Serialize(summaries, CliJsonContext.Default.IReadOnlyListRepoSummary));
Terminal.OutVerbatim(JsonSerializer.Serialize(summaries, CliJsonContext.Default.IReadOnlyListRepoSummary));
return ExitCodes.Success;
}
if(repositories.Count == 0)
{
Console.Error.WriteLine(listed == 0
Terminal.Error(listed == 0
? $"No repositories visible in '{org}'."
: $"No repositories of '{org}' match the filters ({listed} listed).");
return ExitCodes.Success;
@@ -149,7 +149,7 @@ internal static class ReposCommand
foreach(RepoDescriptor repo in repositories)
{
string branch = (repo.DefaultBranch ?? "-").PadRight(branchWidth);
Console.Out.WriteLine(
Terminal.Out(
$"{repo.Name.PadRight(nameWidth)} {branch}" + (repo.IsArchived ? " archived" : ""));
}
return ExitCodes.Success;
+10 -10
View File
@@ -149,7 +149,7 @@ internal static class SyncCommand
switch(reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
Terminal.OutVerbatim(HelpText);
return null;
case "--org":
options.Org = reader.RequireValue();
@@ -232,11 +232,11 @@ internal static class SyncCommand
var result = new SyncJsonResult(
"summary", summary.Total, summary.Cloned, summary.Updated, summary.Failed,
summary.Canceled, summary.WasCanceled, printer.Failures, printer.Sanitized);
Console.Out.WriteLine(JsonSerializer.Serialize(result, CliJsonContext.Default.SyncJsonResult));
Terminal.OutVerbatim(JsonSerializer.Serialize(result, CliJsonContext.Default.SyncJsonResult));
}
else
{
Console.Out.WriteLine(summaryLine);
Terminal.Out(summaryLine);
}
if(account is not null)
@@ -440,16 +440,16 @@ internal static class SyncCommand
if(json)
{
Console.Out.WriteLine(JsonSerializer.Serialize(
Terminal.OutVerbatim(JsonSerializer.Serialize(
(IReadOnlyList<DryRunEntry>)entries, CliJsonContext.Default.IReadOnlyListDryRunEntry));
return ExitCodes.Success;
}
foreach(DryRunEntry entry in entries)
{
Console.Out.WriteLine($"{entry.Repo} would {entry.Action}");
Terminal.Out($"{entry.Repo} would {entry.Action}");
}
Console.Out.WriteLine($"Dry run: {clones} would clone, {entries.Count - clones} would update of {entries.Count}.");
Terminal.Out($"Dry run: {clones} would clone, {entries.Count - clones} would update of {entries.Count}.");
return ExitCodes.Success;
}
@@ -499,7 +499,7 @@ internal static class SyncCommand
catch(Exception ex)
{
log.Error($"could not record the sync result on account '{account.Name}': {ex.Message}", ex);
Console.Error.WriteLine(
Terminal.Error(
$"Warning: could not record the sync result on account '{account.Name}': {ex.Message}");
}
}
@@ -546,7 +546,7 @@ internal static class SyncCommand
string detail = DescribeRecovery(recovery);
if(announce)
{
Console.Error.WriteLine(
Terminal.Error(
$"{pending.Repo} Sanitized {recovery.SegmentRenames.Count} renamed, "
+ $"{recovery.SkippedPaths.Count} skipped: {detail}");
}
@@ -703,11 +703,11 @@ internal static class SyncCommand
// the first three offending paths.
foreach(InvalidPathInfo info in value.InvalidPaths.Take(MaxListedPaths))
{
Console.Error.WriteLine($"{value.RepoName} {info.RepoPath} ({info.Reason})");
Terminal.Error($"{value.RepoName} {info.RepoPath} ({info.Reason})");
}
if(value.InvalidPaths.Count > MaxListedPaths)
{
Console.Error.WriteLine(
Terminal.Error(
$"{value.RepoName} ... and {value.InvalidPaths.Count - MaxListedPaths} more invalid paths");
}
}
+114
View File
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text;
using gclo.Engine;
namespace gclo.Cli;
/// <summary>
/// The one place the command line writes to standard output and standard error. Text
/// that is assembled at run time can quote a repository, a server, or a file on disk,
/// so it goes out through <see cref="Out"/> and <see cref="Error"/>, which make it
/// safe for a terminal. Only text that is fixed in the program (help, the version) or
/// produced by the JSON encoder (which emits ASCII and escapes everything else) is
/// written as it is, through <see cref="OutVerbatim"/> (#143).
/// </summary>
/// <remarks>
/// Safe for a terminal means two things. Characters that would break a line, steer
/// the terminal, or reorder or hide the text around them are shown in visible form
/// (see <see cref="DisplayText"/>). And because what reaches the terminal is bytes in
/// the console's code page, a character that code page cannot represent is shown the
/// same way: a legacy code page otherwise "best-fits" it to a different byte, and for
/// some ordinary printable characters that byte is a control character (on code page
/// 437, a left arrow becomes ESC).
/// </remarks>
internal static class Terminal
{
/// <summary>Writes one line of run-time text to standard output, made safe for the terminal.</summary>
public static void Out(string text) => Console.Out.WriteLine(Safe(text, Console.Out.Encoding));
/// <summary>Writes one line of run-time text to standard error, made safe for the terminal.</summary>
public static void Error(string text) => Console.Error.WriteLine(Safe(text, Console.Error.Encoding));
/// <summary>
/// Writes text to standard output exactly as given. For text fixed in the program
/// and for JSON documents only; never for anything that quotes run-time data.
/// </summary>
public static void OutVerbatim(string text) => Console.Out.WriteLine(text);
/// <summary>Writes a fixed prompt to standard error, without a line break after it.</summary>
public static void ErrorPrompt(string fixedText) => Console.Error.Write(fixedText);
/// <summary>Ends the current line on standard error.</summary>
public static void ErrorLineBreak() => Console.Error.WriteLine();
/// <summary>
/// <paramref name="text"/> as one line that is safe to send to a terminal whose
/// output encoding is <paramref name="encoding"/>.
/// </summary>
internal static string Safe(string? text, Encoding encoding)
{
ArgumentNullException.ThrowIfNull(encoding);
string visible = DisplayText.Escape(text);
if(IsUnicode(encoding))
{
return visible;
}
StringBuilder? builder = null;
int index = 0;
foreach(Rune rune in visible.EnumerateRunes())
{
int length = rune.Utf16SequenceLength;
if(rune.IsAscii || RoundTrips(encoding, visible.AsSpan(index, length)))
{
builder?.Append(visible, index, length);
}
else
{
builder ??= new StringBuilder(visible.Length + 16).Append(visible, 0, index);
for(int unit = 0; unit < length; unit++)
{
DisplayText.AppendVisible(builder, visible[index + unit]);
}
}
index += length;
}
return builder?.ToString() ?? visible;
}
/// <summary>UTF-8, UTF-16 and UTF-32 represent every character; nothing is best-fitted.</summary>
private static bool IsUnicode(Encoding encoding)
=> encoding.CodePage is 65001 or 1200 or 1201 or 12000 or 12001;
/// <summary>True when <paramref name="character"/> survives encoding and decoding unchanged.</summary>
private static bool RoundTrips(Encoding encoding, ReadOnlySpan<char> character)
{
Span<byte> bytes = stackalloc byte[16];
Span<char> back = stackalloc char[8];
int byteCount = encoding.GetBytes(character, bytes);
int charCount = encoding.GetChars(bytes[..byteCount], back);
return back[..charCount].SequenceEqual(character);
}
}
+2 -2
View File
@@ -173,9 +173,9 @@ internal sealed class TokenOptions
// Interactive use: prompt on stderr (redirected stdout stays clean) and read
// WITHOUT echo, like git/gh credential prompts — an echoed token would sit on
// screen and in the terminal scrollback.
Console.Error.Write("Token: ");
Terminal.ErrorPrompt("Token: ");
string line = ReadLineNoEcho().Trim();
Console.Error.WriteLine();
Terminal.ErrorLineBreak();
return line;
}
+51
View File
@@ -43,6 +43,12 @@ public sealed class ActivityLogTests : IDisposable
/// <summary>The visible form of one UTF-16 code unit: a backslash, "u", four upper-case hex digits.</summary>
private static string Visible(int unit)
=> "\\" + "u" + unit.ToString("X4", System.Globalization.CultureInfo.InvariantCulture);
// ---------------------------------------------------------------- line format
[Fact]
@@ -75,6 +81,51 @@ public sealed class ActivityLogTests : IDisposable
[Fact]
public void Message_WithLineBreaksAndEscapeCharacters_IsStillOneEntryOnOneLine()
{
var log = new FileActivityLog(_root);
// What a repository can put into a message: a file path with a newline that
// imitates the start of another entry, and a terminal escape sequence.
string forged = "repo failed: bad\n2026-01-01 00:00:00.000 [INFO] all repositories synced" + (char)0x1B + "[2J";
log.Error(forged);
string line = Assert.Single(File.ReadAllLines(log.CurrentLogFilePath));
Assert.Matches(s_linePattern, line);
Assert.Contains("[ERROR] repo failed: bad", line);
Assert.Contains(Visible(0x0A) + "2026-01-01 00:00:00.000 [INFO] all repositories synced", line);
Assert.EndsWith(Visible(0x1B) + "[2J", line, StringComparison.Ordinal);
Assert.DoesNotContain((char)0x1B, line);
}
[Fact]
public void Exception_WhoseTextImitatesAnEntry_CannotStartALineInTheFirstColumn()
{
var log = new FileActivityLog(_root);
var exception = new InvalidOperationException(
"bad path 'a\n2026-01-01 00:00:00.000 [INFO] forged entry' in tree" + (char)0x1B + "[0m");
log.Error("sync blew up", exception);
log.Info("next entry");
string[] lines = File.ReadAllLines(log.CurrentLogFilePath);
// Exactly two lines look like entries: the real ones.
Assert.Equal(2, lines.Count(l => s_linePattern.IsMatch(l)));
Assert.Matches(s_linePattern, lines[0]);
Assert.Matches(s_linePattern, lines[^1]);
Assert.All(lines[1..^1], l => Assert.StartsWith(" ", l, StringComparison.Ordinal));
// The indent is what keeps a continuation line from matching an entry's start,
// for this pattern and for the Log window's filter alike.
Assert.All(lines[1..^1], l => Assert.DoesNotMatch(s_linePattern, l));
Assert.Contains(lines, l => l.Contains("2026-01-01 00:00:00.000 [INFO] forged entry", StringComparison.Ordinal));
Assert.DoesNotContain(lines, l => l.Contains((char)0x1B));
}
[Fact]
public void Error_WithException_AppendsExceptionTextOnFollowingLines()
{
+220
View File
@@ -0,0 +1,220 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Text;
namespace gclo.Engine.Tests;
/// <summary>
/// <see cref="DisplayText"/> turns characters that would break a log line, or reorder
/// or hide the text around them, into visible text, and leaves everything else alone
/// (#143).
/// </summary>
public sealed class DisplayTextTests
{
private const char Escape = (char)0x1B;
[Theory]
[InlineData("plain text")]
[InlineData("src/app/Main.cs")]
[InlineData("café/日本語.txt")]
[InlineData("emoji 😀 stays")]
[InlineData(@"C:\Users\someone\src\repo")]
[InlineData(" ")]
public void Escape_OrdinaryText_ComesBackUnchanged_AsTheSameString(string text)
{
Assert.Same(text, DisplayText.Escape(text));
}
[Fact]
public void Escape_TextThatNeedsJoiners_ComesBackUnchanged()
{
// A family emoji is three people held together by zero-width joiners, and
// Persian uses the zero-width non-joiner inside ordinary words.
string family = char.ConvertFromUtf32(0x1F468) + (char)0x200D + char.ConvertFromUtf32(0x1F469) + (char)0x200D + char.ConvertFromUtf32(0x1F467);
string persian = "" + (char)0x0645 + (char)0x06CC + (char)0x200C + (char)0x062E;
Assert.Same(family, DisplayText.Escape(family));
Assert.Same(persian, DisplayText.Escape(persian));
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void Escape_NothingToShow_IsEmpty(string? text)
{
Assert.Equal("", DisplayText.Escape(text));
}
[Fact]
public void Escape_ControlCharacters_BecomeVisible_OnOneLine()
{
Assert.Equal("a" + Visible(0x0A) + "b", DisplayText.Escape("a\nb"));
Assert.Equal("a" + Visible(0x0D) + Visible(0x0A) + "b", DisplayText.Escape("a\r\nb"));
Assert.Equal("col1" + Visible(0x09) + "col2", DisplayText.Escape("col1\tcol2"));
Assert.Equal(Visible(0x1B) + "[2Jwiped", DisplayText.Escape(Escape + "[2Jwiped"));
Assert.Equal("bell" + Visible(0x07), DisplayText.Escape("bell" + (char)0x07));
Assert.Equal("nul" + Visible(0x00), DisplayText.Escape("nul" + (char)0x00));
Assert.Equal("del" + Visible(0x7F), DisplayText.Escape("del" + (char)0x7F));
Assert.Equal("nel" + Visible(0x85), DisplayText.Escape("nel" + (char)0x85));
Assert.Equal("csi" + Visible(0x9B) + "31m", DisplayText.Escape("csi" + (char)0x9B + "31m"));
}
[Theory]
[InlineData(0x2028)] // line separator
[InlineData(0x2029)] // paragraph separator
[InlineData(0x200E)] // left-to-right mark
[InlineData(0x200F)] // right-to-left mark
[InlineData(0x061C)] // Arabic letter mark
[InlineData(0x202A)] // left-to-right embedding
[InlineData(0x202E)] // right-to-left override
[InlineData(0x2066)] // left-to-right isolate
[InlineData(0x2069)] // pop directional isolate
[InlineData(0x206A)] // deprecated format characters
[InlineData(0x200B)] // zero-width space
[InlineData(0x2060)] // word joiner
[InlineData(0xFEFF)] // zero-width no-break space (byte order mark)
[InlineData(0x00AD)] // soft hyphen
public void Escape_SeparatorsDirectionAndInvisibleCharacters_BecomeVisible(int code)
{
string text = "invoice" + (char)code + "fdp.exe";
string escaped = DisplayText.Escape(text);
Assert.Equal("invoice" + Visible(code) + "fdp.exe", escaped);
Assert.DoesNotContain((char)code, escaped);
}
[Theory]
[InlineData(0x2027)] // hyphenation point: punctuation
[InlineData(0x202F)] // narrow no-break space: a space
[InlineData(0x2065)] // unassigned
[InlineData(0x200C)] // zero-width non-joiner
[InlineData(0x200D)] // zero-width joiner
[InlineData(0xFE0F)] // variation selector: a mark, used by emoji
[InlineData(0xE000)] // private use
public void Escape_CharactersThatOrdinaryTextUses_AreLeftAlone(int code)
{
string text = "a" + (char)code + "b";
Assert.Same(text, DisplayText.Escape(text));
}
[Fact]
public void Escape_TagCharacters_WhichAreInvisibleAndCanCarryHiddenText_BecomeVisible_AsTheirTwoCodeUnits()
{
string tag = char.ConvertFromUtf32(0xE0041); // "tag latin capital letter a"
string escaped = DisplayText.Escape("a" + tag + "b");
Assert.Equal("a" + Visible(tag[0]) + Visible(tag[1]) + "b", escaped);
}
[Fact]
public void Escape_HalfOfASurrogatePair_BecomesVisible_WhereverItStands()
{
char high = char.ConvertFromUtf32(0x1F600)[0];
char low = char.ConvertFromUtf32(0x1F600)[1];
Assert.Equal("a" + Visible(high) + "b", DisplayText.Escape("a" + high + "b"));
Assert.Equal("a" + Visible(low) + "b", DisplayText.Escape("a" + low + "b"));
Assert.Equal("a" + Visible(high), DisplayText.Escape("a" + high));
Assert.Equal(Visible(low) + Visible(high), DisplayText.Escape("" + low + high));
}
[Fact]
public void Escape_KeepsEverythingBeforeAndAfterTheFirstCharacterItChanges()
{
string emoji = char.ConvertFromUtf32(0x1F600);
string text = "start " + emoji + " " + Escape + " middle \n " + emoji + " end";
Assert.Equal(
"start " + emoji + " " + Visible(0x1B) + " middle " + Visible(0x0A) + " " + emoji + " end",
DisplayText.Escape(text));
}
[Fact]
public void EscapeLines_KeepsTheLines_IndentsEachOne_AndEscapesWithinThem()
{
string text = "first\nsecond " + Escape + "[0m\r\nthird\rfourth\n";
string block = DisplayText.EscapeLines(text, " ");
Assert.Equal(
new[] { " first", " second " + Visible(0x1B) + "[0m", " third", " fourth", " " },
block.Split(Environment.NewLine));
}
[Fact]
public void EscapeLines_NoIndent_StillOneEscapedLinePerLine()
{
Assert.Equal("a" + Environment.NewLine + "b", DisplayText.EscapeLines("a\nb", ""));
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void EscapeLines_NothingToShow_IsEmpty(string? text)
{
Assert.Equal("", DisplayText.EscapeLines(text, " "));
}
[Fact]
public void EscapeLines_NullIndent_IsACallerError()
{
Assert.Throws<ArgumentNullException>(() => DisplayText.EscapeLines("text", null!));
}
[Fact]
public void AppendVisible_WritesBackslashUAndFourUpperCaseHexDigits()
{
var builder = new StringBuilder("x");
DisplayText.AppendVisible(builder, (char)0x1B);
DisplayText.AppendVisible(builder, (char)0xFEFF);
DisplayText.AppendVisible(builder, (char)0x000A);
Assert.Equal("x" + Visible(0x1B) + Visible(0xFEFF) + Visible(0x0A), builder.ToString());
Assert.Equal(6, Visible(0x1B).Length);
Assert.Throws<ArgumentNullException>(() => DisplayText.AppendVisible(null!, 'a'));
}
/// <summary>The expected visible form: a backslash, "u", and four upper-case hex digits.</summary>
private static string Visible(int code)
=> "\\" + "u" + code.ToString("X4", CultureInfo.InvariantCulture);
}
+168
View File
@@ -0,0 +1,168 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Text;
namespace gclo.Engine;
/// <summary>
/// Makes text that came from somewhere else safe to put in a log line. Repository
/// content reaches the log: a file path in a git tree may contain a newline, an escape
/// character, or a character that reorders or hides the text around it, and such a
/// path is exactly what gclo reports when it cannot be created on Windows. Left alone,
/// a newline starts what reads as a new log entry (#143).
/// </summary>
/// <remarks>
/// <para>
/// What becomes visible is decided per code point, by Unicode category: control
/// characters (C0, DEL, C1), line and paragraph separators, format characters (the
/// text-direction marks, embeddings, overrides and isolates; zero-width and other
/// invisible characters; tag characters), and a surrogate that has no partner. The
/// zero-width joiner and non-joiner stay: emoji sequences and several scripts are
/// written with them. Everything else, in any script, is left as it is.
/// </para>
/// <para>
/// The visible form is a backslash, the letter u and four upper-case hexadecimal
/// digits, once per UTF-16 code unit. A path that already contains those six
/// characters reads the same way; the form is for people, and nothing reads it back.
/// </para>
/// <para>
/// A terminal needs more than this: what reaches it is bytes in the console's code
/// page, and a legacy code page turns some ordinary characters into control bytes.
/// The command line writes through its own layer for that.
/// </para>
/// </remarks>
public static class DisplayText
{
private const string HexDigits = "0123456789ABCDEF";
private static readonly string[] s_lineBreaks = ["\r\n", "\n", "\r"];
/// <summary>
/// <paramref name="text"/> with every character described in the type remarks
/// replaced by its visible form; the result is always one line. Text that needs no
/// change comes back as the same string. Null gives an empty string.
/// </summary>
public static string Escape(string? text)
{
if(string.IsNullOrEmpty(text))
{
return "";
}
int first = 0;
while(first < text.Length)
{
if(IsUnsafeAt(text, first, out int length))
{
break;
}
first += length;
}
if(first == text.Length)
{
return text;
}
var builder = new StringBuilder(text.Length + 16);
builder.Append(text, 0, first);
for(int i = first; i < text.Length;)
{
bool unsafeHere = IsUnsafeAt(text, i, out int length);
for(int unit = 0; unit < length; unit++)
{
if(unsafeHere)
{
AppendVisible(builder, text[i + unit]);
}
else
{
builder.Append(text[i + unit]);
}
}
i += length;
}
return builder.ToString();
}
/// <summary>
/// <paramref name="text"/> as a block of lines: its own line breaks are kept, every
/// line is escaped like <see cref="Escape"/> and starts with
/// <paramref name="indent"/>. With a non-empty indent no line of the block can be
/// taken for something that starts in the first column, such as a log entry's
/// timestamp. Null or empty text gives an empty string.
/// </summary>
public static string EscapeLines(string? text, string indent)
{
ArgumentNullException.ThrowIfNull(indent);
if(string.IsNullOrEmpty(text))
{
return "";
}
return string.Join(
Environment.NewLine,
text.Split(s_lineBreaks, StringSplitOptions.None).Select(line => indent + Escape(line)));
}
/// <summary>
/// Appends the visible form of one UTF-16 code unit to <paramref name="builder"/>:
/// a backslash, the letter u, and four upper-case hexadecimal digits.
/// </summary>
public static void AppendVisible(StringBuilder builder, char unit)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Append('\\').Append('u');
for(int shift = 12; shift >= 0; shift -= 4)
{
builder.Append(HexDigits[(unit >> shift) & 0xF]);
}
}
/// <summary>
/// Whether the code point that starts at <paramref name="index"/> must be made
/// visible, and how many UTF-16 code units it takes (two for a surrogate pair).
/// </summary>
private static bool IsUnsafeAt(string text, int index, out int length)
{
char c = text[index];
if(char.IsHighSurrogate(c) && index + 1 < text.Length && char.IsLowSurrogate(text[index + 1]))
{
length = 2;
return IsUnsafeCategory(CharUnicodeInfo.GetUnicodeCategory(char.ConvertToUtf32(c, text[index + 1])));
}
length = 1;
if(char.IsSurrogate(c))
{
return true; // half of a pair: not a character, and writers replace it with something else
}
// The zero-width non-joiner and joiner (0x200C, 0x200D) are format characters
// that ordinary text needs.
return c is not ((char)0x200C or (char)0x200D) && IsUnsafeCategory(CharUnicodeInfo.GetUnicodeCategory(c));
}
private static bool IsUnsafeCategory(UnicodeCategory category)
=> category is UnicodeCategory.Control
or UnicodeCategory.Format
or UnicodeCategory.LineSeparator
or UnicodeCategory.ParagraphSeparator;
}
+12 -2
View File
@@ -19,6 +19,9 @@ namespace gclo.Engine;
/// </summary>
public sealed class FileActivityLog : IActivityLog
{
/// <summary>What every line of an exception's text starts with, under its entry.</summary>
private const string ContinuationIndent = " ";
private readonly Lock _gate = new();
@@ -73,10 +76,17 @@ public sealed class FileActivityLog : IActivityLog
string timestamp = DateTime.Now.ToString(
"yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
string entry = $"{timestamp} [{level}] {message}";
///////////////////////////////////////////////////////////////////////////
// An entry is one line that starts with its timestamp; readers (the Log //
// window's error filter among them) rely on that. Messages carry text //
// from repositories, so the message is escaped onto one line, and an //
// exception's lines are indented: nothing in them can start a line that //
// looks like another entry (#143). //
///////////////////////////////////////////////////////////////////////////
string entry = $"{timestamp} [{level}] {DisplayText.Escape(message)}";
if(exception is not null)
{
entry += Environment.NewLine + exception;
entry += Environment.NewLine + DisplayText.EscapeLines(exception.ToString(), ContinuationIndent);
}
File.AppendAllText(CurrentLogFilePath, entry + Environment.NewLine);