fix(cli): exit codes, option handling and Ctrl+C for accounts and dry runs; the log follows GCLO_DATA_DIR #160
No files matched your search
+20
-7
@@ -100,7 +100,13 @@ broken-repo Failed remote authentication failed
|
||||
|
||||
**Ctrl+C** cancels gracefully: in-flight git operations stop, remaining
|
||||
repositories are marked `Canceled`, and the summary still prints. A second
|
||||
Ctrl+C aborts the process immediately.
|
||||
Ctrl+C aborts the process immediately. So does the first one while gclo waits
|
||||
for the token at its prompt (`--token-stdin` in a terminal), where nothing has
|
||||
been started yet. In `gclo accounts`, the first Ctrl+C lets a change that is
|
||||
under way finish (it writes to the credential store and to the accounts file,
|
||||
and is not cut off between the two). A `--dry-run` that is canceled while the
|
||||
repositories are being listed prints no plan, and with `--json` or
|
||||
`--json-lines` no document, and exits 1.
|
||||
|
||||
#### Windows-invalid paths and `--sanitize-paths`
|
||||
|
||||
@@ -290,7 +296,12 @@ gh auth token | gclo accounts add --name work --org contoso --target C:\src\cont
|
||||
```
|
||||
|
||||
`--name`, `--org`, and `--target` are required; `--parallel` (1-64, default 8),
|
||||
`--org-subfolder`, and `--description` are optional. The token comes from a
|
||||
`--org-subfolder`, and `--description` are optional. A relative `--target` is
|
||||
stored as a full path, resolved against the current directory, because the
|
||||
account is used later from other places; a target that cannot be a folder
|
||||
(a character no folder name can have, a device name, an unexpanded `~` or
|
||||
`%NAME%`) is refused. `--description=` (or `--description ""`) is an empty
|
||||
description. The token comes from a
|
||||
token option exactly as for `gclo sync` (default: the `GITHUB_TOKEN` environment
|
||||
variable) and is written to Windows Credential Manager — never put it on the
|
||||
command line. A name that another account already uses (case-insensitive)
|
||||
@@ -393,16 +404,18 @@ still wins for that run (the stored token is left untouched).
|
||||
|
||||
Every invocation appends to a daily activity log file, `gclo-yyyy-MM-dd.log`,
|
||||
under `%LOCALAPPDATA%\KofTwentyTwo\gclo\logs` (on Linux/macOS: the platform's
|
||||
local application data folder). It records the run's parameters, per-repository
|
||||
local application data folder), or under `logs` in the folder `GCLO_DATA_DIR`
|
||||
names when that variable is set, the same folder the desktop app logs to. It records the run's parameters, per-repository
|
||||
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).
|
||||
|
||||
Daily files are kept for 30 days, today included; older ones are removed the
|
||||
first time something is logged on a given day. When the desktop app and the
|
||||
CLI log to the same file (they do unless `GCLO_DATA_DIR` moves the app's data),
|
||||
an entry that finds the file in use is tried again for a moment rather than
|
||||
dropped; if the file stays in use, entries are dropped until it is free again.
|
||||
first time something is logged on a given day. The desktop app and the CLI
|
||||
log to the same file (the stable desktop app, that is; a development build
|
||||
of the app keeps its own data folder); an entry that finds the file in use is
|
||||
tried again for a moment rather than dropped, and if the file stays in use,
|
||||
entries are dropped until it is free again.
|
||||
|
||||
Each entry is one line that starts with its timestamp: local time, followed by
|
||||
its offset from UTC (`2026-11-10 14:03:22.123 -06:00 [INFO] ...`). 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.
|
||||
|
||||
@@ -567,4 +567,294 @@ public sealed class AccountsCommandTests : IDisposable
|
||||
|
||||
Assert.Throws<CliErrorException>(() => AccountsCommand.Run([], failing, new NullLog()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- option handling (#159)
|
||||
|
||||
[Fact]
|
||||
public void ShortHelp_IsHelp_NotAnUnknownSubcommand()
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = AccountsCommand.Run(["-h"], Open, new NullLog());
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Contains("gclo accounts", console.Out);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Add_AnEmptyDescription_IsAccepted_InEitherForm()
|
||||
{
|
||||
using IDisposable _ = TokenEnv("ghp_added");
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
Assert.Equal(0, AccountsCommand.Run(
|
||||
["add", "--name", "one", "--org", "acme", "--target", @"C:\src", "--description=", "--token-env", EnvVar], Open, new NullLog()));
|
||||
Assert.Equal(0, AccountsCommand.Run(
|
||||
["add", "--name", "two", "--org", "acme", "--target", @"C:\src", "--description", "", "--token-env", EnvVar], Open, new NullLog()));
|
||||
|
||||
Assert.All(Store.GetAll(), a => Assert.Equal("", a.Description));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Edit_AnEmptyDescription_ClearsIt()
|
||||
{
|
||||
Seed("work", "acme", @"C:\src");
|
||||
Store.Save(Store.FindByName("work")! with { Description = "day job" }, token: null);
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = AccountsCommand.Run(["edit", "--name", "work", "--description="], Open, new NullLog());
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Equal("", Store.FindByName("work")!.Description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("--rename")]
|
||||
[InlineData("--org")]
|
||||
[InlineData("--target")]
|
||||
public void Edit_ABlankNameOrganizationOrFolder_IsAUsageError_AndChangesNothing(string option)
|
||||
{
|
||||
Seed("work", "acme", @"C:\src");
|
||||
Account before = Store.FindByName("work")!;
|
||||
|
||||
CliUsageException ex = Assert.Throws<CliUsageException>(
|
||||
() => AccountsCommand.Run(["edit", "--name", "work", option, " "], Open, new NullLog()));
|
||||
|
||||
Assert.Equal($"{option} expects a value that is not blank.", ex.Message);
|
||||
Assert.Equal(before, Store.FindByName("work"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ARelativeTarget_IsStoredAsAFullPath_ByAddAndByEdit()
|
||||
{
|
||||
using IDisposable _ = TokenEnv("ghp_added");
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
AccountsCommand.Run(
|
||||
["add", "--name", "work", "--org", "acme", "--target", "repos", "--token-env", EnvVar], Open, new NullLog());
|
||||
string added = Store.FindByName("work")!.TargetRoot;
|
||||
AccountsCommand.Run(["edit", "--name", "work", "--target", Path.Combine("other", "place")], Open, new NullLog());
|
||||
string edited = Store.FindByName("work")!.TargetRoot;
|
||||
|
||||
// The account will be used from other places; "repos" must mean one folder.
|
||||
Assert.Equal(Path.GetFullPath("repos"), added);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine("other", "place")), edited);
|
||||
Assert.True(Path.IsPathFullyQualified(added));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("a|b", "a character no folder name can have")]
|
||||
[InlineData("a<b>", "a character no folder name can have")]
|
||||
[InlineData("repos*", "a character no folder name can have")]
|
||||
[InlineData("~", "are not expanded by gclo")]
|
||||
[InlineData(@"~\src", "are not expanded by gclo")]
|
||||
[InlineData(@"%USERPROFILE%\src", "are not expanded by gclo")]
|
||||
[InlineData("repos:x", "a colon belongs after a drive letter only")]
|
||||
[InlineData(@"C:\src:stream", "a colon belongs after a drive letter only")]
|
||||
[InlineData("con", "it names a device, not a folder")]
|
||||
public void ATargetThatCannotBeAFolder_IsAUsageError_BeforeTheTokenIsEvenRead(string target, string reason)
|
||||
{
|
||||
if(!OperatingSystem.IsWindows() && string.Equals(target, "con", StringComparison.Ordinal))
|
||||
{
|
||||
return; // device names are a Windows notion
|
||||
}
|
||||
using var stdin = new StdinRedirect("ghp_not_to_be_read\n");
|
||||
|
||||
CliUsageException add = Assert.Throws<CliUsageException>(() => AccountsCommand.Run(
|
||||
["add", "--name", "work", "--org", "acme", "--target", target, "--token-stdin"], Open, new NullLog()));
|
||||
Seed("existing", "acme", @"C:\src");
|
||||
CliUsageException edit = Assert.Throws<CliUsageException>(() => AccountsCommand.Run(
|
||||
["edit", "--name", "existing", "--target", target, "--token-stdin"], Open, new NullLog()));
|
||||
|
||||
Assert.Contains("is not a usable folder path", add.Message);
|
||||
Assert.Contains(reason, add.Message);
|
||||
Assert.Contains(reason, edit.Message);
|
||||
Assert.Null(Store.FindByName("work"));
|
||||
Assert.Equal(@"C:\src", Store.FindByName("existing")!.TargetRoot);
|
||||
Assert.Equal("ghp_not_to_be_read", Console.In.ReadLine()); // the token line is still there: nothing read it
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ATargetLongerThanAnyPathCanBe_IsAUsageError()
|
||||
{
|
||||
using IDisposable _ = TokenEnv("ghp_added");
|
||||
string tooLong = new('a', 40_000);
|
||||
|
||||
CliUsageException ex = Assert.Throws<CliUsageException>(() => AccountsCommand.Run(
|
||||
["add", "--name", "work", "--org", "acme", "--target", tooLong, "--token-env", EnvVar], Open, new NullLog()));
|
||||
|
||||
Assert.Contains("is not a usable folder path", ex.Message);
|
||||
Assert.Empty(Store.GetAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ATargetWithADriveLetter_OrAFullPath_IsKeptAsItIs()
|
||||
{
|
||||
if(!OperatingSystem.IsWindows())
|
||||
{
|
||||
return; // drive letters
|
||||
}
|
||||
using IDisposable _ = TokenEnv("ghp_added");
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
AccountsCommand.Run(["add", "--name", "work", "--org", "acme", "--target", @"D:\mirror\acme", "--token-env", EnvVar], Open, new NullLog());
|
||||
|
||||
Assert.Equal(@"D:\mirror\acme", Store.FindByName("work")!.TargetRoot);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- failures of the file or the credential store (#159)
|
||||
|
||||
/// <summary>Makes the next write of accounts.json fail: a folder sits where its temporary file goes.</summary>
|
||||
private void BlockAccountsFile() => Directory.CreateDirectory(Path.Combine(_dir, "accounts.json.tmp"));
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AFailureToWriteTheAccountsFile_IsAFatalError_NotAnUnexpectedOne()
|
||||
{
|
||||
using IDisposable _ = TokenEnv("ghp_added");
|
||||
Seed("work", "acme", @"C:\src");
|
||||
BlockAccountsFile();
|
||||
|
||||
CliErrorException add = Assert.Throws<CliErrorException>(() => AccountsCommand.Run(
|
||||
["add", "--name", "other", "--org", "acme", "--target", @"C:\src", "--token-env", EnvVar], Open, new NullLog()));
|
||||
CliErrorException edit = Assert.Throws<CliErrorException>(
|
||||
() => AccountsCommand.Run(["edit", "--name", "work", "--org", "elsewhere"], Open, new NullLog()));
|
||||
CliErrorException remove = Assert.Throws<CliErrorException>(
|
||||
() => AccountsCommand.Run(["remove", "--name", "work"], Open, new NullLog()));
|
||||
|
||||
Assert.All(new[] { add, edit, remove }, ex =>
|
||||
{
|
||||
Assert.Equal(ExitCodes.Fatal, ex.ExitCode);
|
||||
Assert.IsType<UnauthorizedAccessException>(ex.InnerException);
|
||||
});
|
||||
Assert.Equal("acme", Store.FindByName("work")!.Organization);
|
||||
Assert.Equal("tok-work", _vault.TryRetrieve(Store.FindByName("work")!.Id)); // the removal was undone
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A vault that fails the way Credential Manager does when it cannot be used.</summary>
|
||||
private sealed class BrokenVault : ITokenVault
|
||||
{
|
||||
// What CredentialManagerVault throws: the call that failed and the number, no words for it.
|
||||
public void Store(Guid accountId, string token)
|
||||
=> throw new System.ComponentModel.Win32Exception(1312, "CredWriteW failed (error 1312) for target 'gclo:account:test'.");
|
||||
|
||||
public string? TryRetrieve(Guid accountId) => null;
|
||||
|
||||
public void Delete(Guid accountId)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AFailureOfTheCredentialStore_IsAFatalError_WithItsReason()
|
||||
{
|
||||
using IDisposable _ = TokenEnv("ghp_added");
|
||||
var vault = new BrokenVault();
|
||||
var store = new AccountsStore(vault, _dir, new NullLog());
|
||||
|
||||
CliErrorException ex = Assert.Throws<CliErrorException>(() => AccountsCommand.Run(
|
||||
["add", "--name", "work", "--org", "acme", "--target", @"C:\src", "--token-env", EnvVar], _ => (store, vault), new NullLog()));
|
||||
|
||||
Assert.Equal(ExitCodes.Fatal, ex.ExitCode);
|
||||
Assert.StartsWith("CredWriteW failed (error 1312)", ex.Message);
|
||||
if(OperatingSystem.IsWindows())
|
||||
{
|
||||
// The number alone says nothing; what Windows means by it is added.
|
||||
Assert.Contains("logon session", ex.Message);
|
||||
}
|
||||
Assert.Empty(store.GetAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AnEdit_OfAnAccountAnotherGcloProcessRemovedMeanwhile_IsAFatalError_NotAnUnexpectedOne()
|
||||
{
|
||||
Seed("work", "acme", @"C:\src");
|
||||
Account work = Store.FindByName("work")!;
|
||||
// The store this command works with still lists the account; the file no longer does.
|
||||
new AccountsStore(_vault, _dir, new NullLog()).Delete(work.Id);
|
||||
|
||||
CliErrorException ex = Assert.Throws<CliErrorException>(
|
||||
() => AccountsCommand.Run(["edit", "--name", "work", "--org", "elsewhere"], Open, new NullLog()));
|
||||
|
||||
Assert.Equal(ExitCodes.Fatal, ex.ExitCode);
|
||||
Assert.Contains("was removed by another gclo process", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AChange_WhileAnotherGcloProcessHoldsTheAccountsLock_IsRetryLater_AfterTheStoresOwnWait()
|
||||
{
|
||||
// The real path, once: the store waits its few seconds for the lock, then says so.
|
||||
Seed("work", "acme", @"C:\src");
|
||||
using var held = new FileStream(Path.Combine(_dir, "accounts.json.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
|
||||
|
||||
CliErrorException ex = Assert.Throws<CliErrorException>(
|
||||
() => AccountsCommand.Run(["remove", "--name", "work"], Open, new NullLog()));
|
||||
|
||||
Assert.Equal(ExitCodes.Transient, ex.ExitCode);
|
||||
Assert.Contains("in use by another gclo process", ex.Message);
|
||||
Assert.NotNull(Store.FindByName("work"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("list")]
|
||||
[InlineData("add")]
|
||||
[InlineData("edit")]
|
||||
[InlineData("remove")]
|
||||
public void AnAccountsFileThatAnotherGcloProcessIsChanging_IsRetryLater(string subcommand)
|
||||
{
|
||||
using IDisposable _ = TokenEnv("ghp_added");
|
||||
Func<IActivityLog, (AccountsStore, ITokenVault)> busy =
|
||||
_ => throw new AccountsFileBusyException("The accounts file is in use by another gclo process.", new IOException("sharing violation"));
|
||||
string[] args = subcommand switch
|
||||
{
|
||||
"add" => ["add", "--name", "work", "--org", "acme", "--target", @"C:\src", "--token-env", EnvVar],
|
||||
"edit" => ["edit", "--name", "work", "--org", "elsewhere"],
|
||||
"remove" => ["remove", "--name", "work"],
|
||||
_ => ["list"],
|
||||
};
|
||||
|
||||
CliErrorException ex = Assert.Throws<CliErrorException>(() => AccountsCommand.Run(args, busy, new NullLog()));
|
||||
|
||||
Assert.Equal(ExitCodes.Transient, ex.ExitCode);
|
||||
Assert.Contains("in use by another gclo process", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AFailureThatIsNeitherTheFileNorTheStore_StaysWhatItIs()
|
||||
{
|
||||
Func<IActivityLog, (AccountsStore, ITokenVault)> bug = _ => throw new InvalidOperationException("a bug");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => AccountsCommand.Run(["list"], bug, new NullLog()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Cli.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The first Ctrl+C is held back for the commands that have something to finish
|
||||
/// (a run that stops gracefully, a change to the accounts that must not be cut off
|
||||
/// between its two stores), not while waiting at a prompt, and not twice (#159).
|
||||
/// </summary>
|
||||
public sealed class CancelKeyPolicyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("sync")]
|
||||
[InlineData("repos")]
|
||||
[InlineData("orgs")]
|
||||
[InlineData("accounts")]
|
||||
public void ACommandWithSomethingToFinish_IsNotCutOff(string command)
|
||||
=> Assert.True(CancelKeyPolicy.HoldsBack(command, isReadingInput: false, alreadyRequested: false));
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("help")]
|
||||
[InlineData("--version")]
|
||||
[InlineData("no-such-command")]
|
||||
[InlineData(null)]
|
||||
public void AnythingElse_EndsAtOnce(string? command)
|
||||
=> Assert.False(CancelKeyPolicy.HoldsBack(command, isReadingInput: false, alreadyRequested: false));
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("sync")]
|
||||
[InlineData("accounts")]
|
||||
public void WhileWaitingForALineOfInput_TheProcessEndsAtOnce(string command)
|
||||
=> Assert.False(CancelKeyPolicy.HoldsBack(command, isReadingInput: true, alreadyRequested: false));
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("sync")]
|
||||
[InlineData("accounts")]
|
||||
public void ASecondCtrlC_EndsTheProcess(string command)
|
||||
=> Assert.False(CancelKeyPolicy.HoldsBack(command, isReadingInput: false, alreadyRequested: true));
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TheNotice_SaysWhatIsHappening_WhichForAnAccountsChangeIsNotACancellation()
|
||||
{
|
||||
Assert.StartsWith("Canceling...", CancelKeyPolicy.Notice("sync"));
|
||||
Assert.StartsWith("Canceling...", CancelKeyPolicy.Notice(null));
|
||||
Assert.StartsWith("Finishing the change to the accounts...", CancelKeyPolicy.Notice("accounts"));
|
||||
}
|
||||
}
|
||||
@@ -105,4 +105,38 @@ public sealed class OptionReaderTests
|
||||
|
||||
reader.RejectValue(); // must not throw
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- a value that may be empty (#159)
|
||||
|
||||
[Fact]
|
||||
public void RequireValueOrEmpty_AcceptsAnEmptyInlineValue_AndAnEmptyArgument()
|
||||
{
|
||||
var inline = new OptionReader(["--note="]);
|
||||
Assert.True(inline.MoveNext());
|
||||
Assert.Equal("", inline.RequireValueOrEmpty());
|
||||
|
||||
var separate = new OptionReader(["--note", ""]);
|
||||
Assert.True(separate.MoveNext());
|
||||
Assert.Equal("", separate.RequireValueOrEmpty());
|
||||
|
||||
var given = new OptionReader(["--note=text"]);
|
||||
Assert.True(given.MoveNext());
|
||||
Assert.Equal("text", given.RequireValueOrEmpty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void RequireValueOrEmpty_StillNeedsAValue_AndRequireValueStillRefusesAnEmptyOne()
|
||||
{
|
||||
var missing = new OptionReader(["--note"]);
|
||||
Assert.True(missing.MoveNext());
|
||||
Assert.Throws<CliUsageException>(() => missing.RequireValueOrEmpty());
|
||||
|
||||
var empty = new OptionReader(["--name="]);
|
||||
Assert.True(empty.MoveNext());
|
||||
Assert.Throws<CliUsageException>(() => empty.RequireValue());
|
||||
}
|
||||
}
|
||||
@@ -754,4 +754,81 @@ public sealed class SyncCommandTests : IDisposable
|
||||
Assert.Equal(1, code);
|
||||
Assert.Contains("and 5 more invalid paths", console.Error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- a dry run that was canceled (#159)
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task DryRun_CanceledWhileListing_SaysSo_AndDoesNotPrintAnEmptyPlan(bool json)
|
||||
{
|
||||
using var console = new ConsoleCapture();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
string[] args = json
|
||||
? ["--org", "acme", "--target", Target, "--token-env", EnvVar, "--dry-run", "--json"]
|
||||
: ["--org", "acme", "--target", Target, "--token-env", EnvVar, "--dry-run"];
|
||||
|
||||
int code = await SyncCommand.RunAsync(args, new FakeRepoLister { Result = [Repo("a")] }, new FakeGit(), Open, new NullLog(), cts.Token);
|
||||
|
||||
Assert.Equal(ExitCodes.Partial, code);
|
||||
Assert.Contains("Canceled.", console.Error);
|
||||
Assert.Equal("", console.Out); // no plan, and no document that says "nothing to do"
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- sync --account when the stores cannot be used (#159)
|
||||
|
||||
/// <summary>A vault whose reads fail the way Credential Manager's do when it cannot be used.</summary>
|
||||
private sealed class UnreadableVault : ITokenVault
|
||||
{
|
||||
public void Store(Guid accountId, string token)
|
||||
{
|
||||
}
|
||||
|
||||
public string? TryRetrieve(Guid accountId)
|
||||
=> throw new System.ComponentModel.Win32Exception(1312, "CredReadW failed (error 1312) for target 'gclo:account:test'.");
|
||||
|
||||
public void Delete(Guid accountId)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Account_WhoseTokenCannotBeReadFromTheCredentialStore_IsAFatalError_NotAnUnexpectedOne()
|
||||
{
|
||||
Seed("work", "acme", orgSubfolder: false);
|
||||
var vault = new UnreadableVault();
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
|
||||
CliErrorException ex = await Assert.ThrowsAsync<CliErrorException>(() => SyncCommand.RunAsync(
|
||||
["--account", "work"], lister, new FakeGit(), _ => (Store, vault), new NullLog(), CancellationToken.None));
|
||||
|
||||
Assert.Equal(ExitCodes.Fatal, ex.ExitCode);
|
||||
Assert.StartsWith("CredReadW failed (error 1312)", ex.Message);
|
||||
Assert.Null(lister.LastToken); // nothing was listed
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Account_WhenTheAccountsCannotBeOpened_IsAFatalError_OrRetryLater()
|
||||
{
|
||||
Func<IActivityLog, (AccountsStore, ITokenVault)> unreadable = _ => throw new UnauthorizedAccessException("Access to the path is denied.");
|
||||
Func<IActivityLog, (AccountsStore, ITokenVault)> busy =
|
||||
_ => throw new AccountsFileBusyException("The accounts file is in use by another gclo process.", new IOException("sharing violation"));
|
||||
|
||||
CliErrorException denied = await Assert.ThrowsAsync<CliErrorException>(() => SyncCommand.RunAsync(
|
||||
["--account", "work"], new FakeRepoLister(), new FakeGit(), unreadable, new NullLog(), CancellationToken.None));
|
||||
CliErrorException retry = await Assert.ThrowsAsync<CliErrorException>(() => SyncCommand.RunAsync(
|
||||
["--account", "work"], new FakeRepoLister(), new FakeGit(), busy, new NullLog(), CancellationToken.None));
|
||||
|
||||
Assert.Equal(ExitCodes.Fatal, denied.ExitCode);
|
||||
Assert.Equal(ExitCodes.Transient, retry.ExitCode);
|
||||
}
|
||||
}
|
||||
@@ -209,4 +209,47 @@ public sealed class TokenOptionsTests
|
||||
|
||||
Assert.Throws<CliUsageException>(() => new TokenOptions().TryConsume(reader));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- waiting for a line (#159)
|
||||
|
||||
/// <summary>Standard input that notes, when it is read, whether the process says it is waiting for input.</summary>
|
||||
private sealed class WatchfulInput(string line) : TextReader
|
||||
{
|
||||
public bool? WasReadingInput { get; private set; }
|
||||
|
||||
public override string? ReadLine()
|
||||
{
|
||||
WasReadingInput = Terminal.IsReadingInput;
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void WhileTheTokenIsReadFromStandardInput_TheProcessSaysItIsWaitingForInput_AndNotAfterwards()
|
||||
{
|
||||
TextReader original = Console.In;
|
||||
var input = new WatchfulInput("ghp_from_stdin");
|
||||
Console.SetIn(input);
|
||||
try
|
||||
{
|
||||
var options = new TokenOptions();
|
||||
var reader = new OptionReader(["--token-stdin"]);
|
||||
Assert.True(reader.MoveNext());
|
||||
Assert.True(options.TryConsume(reader));
|
||||
|
||||
string token = options.Resolve();
|
||||
|
||||
Assert.Equal("ghp_from_stdin", token);
|
||||
Assert.True(input.WasReadingInput);
|
||||
Assert.False(Terminal.IsReadingInput);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetIn(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
-11
@@ -19,6 +19,11 @@ namespace gclo.Cli;
|
||||
/// </summary>
|
||||
internal static class AccountsCommand
|
||||
{
|
||||
/// <summary>Characters that no folder path can contain on Windows, wherever gclo runs.</summary>
|
||||
private static readonly System.Buffers.SearchValues<char> s_neverInAFolderPath = System.Buffers.SearchValues.Create("|<>\"*?");
|
||||
|
||||
|
||||
|
||||
private const string HelpText = """
|
||||
Usage: gclo accounts [list] [--json]
|
||||
gclo accounts add --name <name> --org <name> --target <folder> [options] <token option>
|
||||
@@ -44,7 +49,8 @@ internal static class AccountsCommand
|
||||
add
|
||||
--name <name> Display name; must be unique (case-insensitive).
|
||||
--org <name> GitHub organization or user account to sync.
|
||||
--target <folder> Target root folder.
|
||||
--target <folder> Target root folder. A relative folder is stored as
|
||||
a full path, resolved against the current directory.
|
||||
--parallel <N> Parallel clone/pull count, 1-64 (default 8).
|
||||
--org-subfolder Clone under <target>\<org> instead of <target>.
|
||||
--description <text> Optional note.
|
||||
@@ -77,7 +83,9 @@ internal static class AccountsCommand
|
||||
Exit codes:
|
||||
0 done
|
||||
2 fatal: bad arguments, unknown account, duplicate name, missing token,
|
||||
or not running on Windows
|
||||
not running on Windows, or the accounts file or Windows Credential
|
||||
Manager could not be used (the message says why)
|
||||
4 another gclo process was changing the accounts; run the command again
|
||||
""";
|
||||
|
||||
|
||||
@@ -85,7 +93,7 @@ internal static class AccountsCommand
|
||||
/// <summary>Composition root: wires the real Credential Manager store and file log.</summary>
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
|
||||
Justification = "Wires the real Credential Manager and file log; delegates to the covered core.")]
|
||||
public static int Run(string[] args) => Run(args, Open, new FileActivityLog());
|
||||
public static int Run(string[] args) => Run(args, Open, new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory));
|
||||
|
||||
|
||||
|
||||
@@ -93,8 +101,9 @@ internal static class AccountsCommand
|
||||
{
|
||||
// The first argument names the subcommand unless it is an option, in which
|
||||
// case it is 'list' (the original, option-only form of the command).
|
||||
string subcommand = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal) ? args[0] : "list";
|
||||
string[] rest = string.Equals(subcommand, "list", StringComparison.Ordinal) && (args.Length == 0 || args[0].StartsWith("--", StringComparison.Ordinal))
|
||||
// ('-h' is an option too: 'gclo accounts -h' asks for help, it names no subcommand.)
|
||||
string subcommand = args.Length > 0 && !args[0].StartsWith('-') ? args[0] : "list";
|
||||
string[] rest = string.Equals(subcommand, "list", StringComparison.Ordinal) && (args.Length == 0 || args[0].StartsWith('-'))
|
||||
? args
|
||||
: args[1..];
|
||||
|
||||
@@ -171,6 +180,11 @@ internal static class AccountsCommand
|
||||
}
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
catch(Exception ex) when(CliErrorException.IsStorageFailure(ex))
|
||||
{
|
||||
log.Error($"accounts failed: {ex.Message}", ex);
|
||||
throw CliErrorException.FromStorage(ex);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
// Fatal path: unusable accounts store or non-Windows platform.
|
||||
@@ -216,7 +230,7 @@ internal static class AccountsCommand
|
||||
target = reader.RequireValue();
|
||||
break;
|
||||
case "--description":
|
||||
description = reader.RequireValue();
|
||||
description = reader.RequireValueOrEmpty();
|
||||
break;
|
||||
case "--parallel":
|
||||
parallel = OptionReader.ParseParallel(reader.RequireValue());
|
||||
@@ -243,6 +257,8 @@ internal static class AccountsCommand
|
||||
throw new CliUsageException("--target is required.");
|
||||
}
|
||||
|
||||
string targetRoot = AbsoluteTarget(target);
|
||||
|
||||
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}', token={(tokenOptions.UseDefault ? "default" : "own")}");
|
||||
try
|
||||
{
|
||||
@@ -257,7 +273,7 @@ internal static class AccountsCommand
|
||||
Name = name.Trim(),
|
||||
Description = description.Trim(),
|
||||
Organization = org.Trim(),
|
||||
TargetRoot = target.Trim(),
|
||||
TargetRoot = targetRoot,
|
||||
CreateOrgSubfolder = orgSubfolder,
|
||||
MaxConcurrency = parallel,
|
||||
TokenSource = tokenOptions.UseDefault ? TokenSource.Default : TokenSource.Own,
|
||||
@@ -266,6 +282,11 @@ internal static class AccountsCommand
|
||||
Terminal.Out($"Account '{account.Name}' added" + (tokenOptions.UseDefault ? " (uses the default token)." : "."));
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
catch(Exception ex) when(CliErrorException.IsStorageFailure(ex))
|
||||
{
|
||||
log.Error($"accounts add failed: {ex.Message}", ex);
|
||||
throw CliErrorException.FromStorage(ex);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
log.Error($"accounts add failed: {ex.Message}", ex);
|
||||
@@ -313,7 +334,7 @@ internal static class AccountsCommand
|
||||
target = reader.RequireValue();
|
||||
break;
|
||||
case "--description":
|
||||
description = reader.RequireValue();
|
||||
description = reader.RequireValueOrEmpty();
|
||||
break;
|
||||
case "--parallel":
|
||||
parallel = OptionReader.ParseParallel(reader.RequireValue());
|
||||
@@ -342,6 +363,11 @@ internal static class AccountsCommand
|
||||
+ "--description, --org-subfolder/--no-org-subfolder, or a token option.");
|
||||
}
|
||||
|
||||
RequireNotBlank(rename, "--rename");
|
||||
RequireNotBlank(org, "--org");
|
||||
RequireNotBlank(target, "--target");
|
||||
string? targetRoot = target is null ? null : AbsoluteTarget(target);
|
||||
|
||||
log.Info($"accounts edit started: name='{name.Trim()}'");
|
||||
try
|
||||
{
|
||||
@@ -360,7 +386,7 @@ internal static class AccountsCommand
|
||||
{
|
||||
Name = rename?.Trim() ?? existing.Name,
|
||||
Organization = org?.Trim() ?? existing.Organization,
|
||||
TargetRoot = target?.Trim() ?? existing.TargetRoot,
|
||||
TargetRoot = targetRoot ?? existing.TargetRoot,
|
||||
Description = description?.Trim() ?? existing.Description,
|
||||
MaxConcurrency = parallel ?? existing.MaxConcurrency,
|
||||
CreateOrgSubfolder = orgSubfolder ?? existing.CreateOrgSubfolder,
|
||||
@@ -373,6 +399,11 @@ internal static class AccountsCommand
|
||||
Terminal.Out($"Account '{updated.Name}' updated" + tokenNote);
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
catch(Exception ex) when(CliErrorException.IsStorageFailure(ex))
|
||||
{
|
||||
log.Error($"accounts edit failed: {ex.Message}", ex);
|
||||
throw CliErrorException.FromStorage(ex);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
log.Error($"accounts edit failed: {ex.Message}", ex);
|
||||
@@ -418,6 +449,11 @@ internal static class AccountsCommand
|
||||
Terminal.Out($"Account '{existing.Name}' removed.");
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
catch(Exception ex) when(CliErrorException.IsStorageFailure(ex))
|
||||
{
|
||||
log.Error($"accounts remove failed: {ex.Message}", ex);
|
||||
throw CliErrorException.FromStorage(ex);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
log.Error($"accounts remove failed: {ex.Message}", ex);
|
||||
@@ -453,15 +489,71 @@ internal static class AccountsCommand
|
||||
|
||||
|
||||
|
||||
/// <summary>A duplicate name is the one validation the store itself enforces; everything else is fatal as is.</summary>
|
||||
/// <summary>A value that was given must say something: a blank name, organization, or folder is a mistake.</summary>
|
||||
private static void RequireNotBlank(string? value, string option)
|
||||
{
|
||||
if(value is not null && string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new CliUsageException($"{option} expects a value that is not blank.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The target folder as a full path. An account is used later from other places
|
||||
/// (the desktop app, a scheduled task, another working directory); a relative
|
||||
/// folder would mean a different one each time, so it is resolved once, here.
|
||||
/// What cannot be a folder is refused instead of being stored: characters no
|
||||
/// folder name can have, a device name, and text that a shell was meant to expand
|
||||
/// and did not ('~', '%NAME%'), which would otherwise become a folder of that name.
|
||||
/// </summary>
|
||||
private static string AbsoluteTarget(string target)
|
||||
{
|
||||
string trimmed = target.Trim();
|
||||
string? problem = trimmed.AsSpan().ContainsAny(s_neverInAFolderPath) || trimmed.Any(char.IsControl)
|
||||
? "it has a character no folder name can have"
|
||||
: trimmed.StartsWith('~') || trimmed.Contains('%')
|
||||
? "'~' and '%NAME%' are not expanded by gclo; give the folder itself"
|
||||
: !trimmed.StartsWith(@"\\", StringComparison.Ordinal) && trimmed.Where((c, i) => c == ':' && i != 1).Any()
|
||||
? "a colon belongs after a drive letter only"
|
||||
: null;
|
||||
|
||||
string full = "";
|
||||
if(problem is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
full = Path.GetFullPath(trimmed);
|
||||
}
|
||||
catch(Exception ex) when(ex is ArgumentException or PathTooLongException)
|
||||
{
|
||||
problem = ex.Message;
|
||||
}
|
||||
}
|
||||
if(problem is null && full.StartsWith(@"\\.\", StringComparison.Ordinal))
|
||||
{
|
||||
problem = "it names a device, not a folder";
|
||||
}
|
||||
|
||||
return problem is null
|
||||
? full
|
||||
: throw new CliUsageException($"--target '{target}' is not a usable folder path: {problem}.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Saves, turning what the store refuses (a duplicate name, an account removed meanwhile) into a fatal error with its reason.</summary>
|
||||
private static void SaveOrFail(AccountsStore store, Account account, string? token)
|
||||
{
|
||||
try
|
||||
{
|
||||
store.Save(account, token);
|
||||
}
|
||||
catch(ArgumentException ex)
|
||||
catch(Exception ex) when(ex is ArgumentException or InvalidOperationException)
|
||||
{
|
||||
// A name that is taken, or an account that another gclo process removed
|
||||
// while this command was waiting: the store says which, and nothing was saved.
|
||||
throw new CliErrorException(ex.Message, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Cli;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What the first Ctrl+C does. For the commands gclo knows, the key press is held
|
||||
/// back and the command gets to finish: a sync, a listing, or an organization
|
||||
/// lookup watches for the request and stops gracefully (the summary still prints);
|
||||
/// a change to the accounts does not watch, and is simply let through to its end,
|
||||
/// because it writes to two places (the credential store and the accounts file) and
|
||||
/// must not be cut off between them. While the process sits in a prompt waiting for
|
||||
/// a line, nothing has been started yet, and it ends at once; so does anything on a
|
||||
/// second Ctrl+C.
|
||||
/// </summary>
|
||||
internal static class CancelKeyPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// True when the key press is to be held back; false when the process is to end
|
||||
/// the way Ctrl+C ends any process.
|
||||
/// </summary>
|
||||
/// <param name="command">The command being run (the first argument), or null when there is none.</param>
|
||||
/// <param name="isReadingInput">Whether the process is waiting for a line on standard input right now.</param>
|
||||
/// <param name="alreadyRequested">Whether an earlier Ctrl+C was already held back.</param>
|
||||
public static bool HoldsBack(string? command, bool isReadingInput, bool alreadyRequested)
|
||||
=> !alreadyRequested && !isReadingInput && command is "sync" or "repos" or "orgs" or "accounts";
|
||||
|
||||
|
||||
|
||||
/// <summary>What the user is told when the key press was held back for <paramref name="command"/>.</summary>
|
||||
public static string Notice(string? command)
|
||||
=> string.Equals(command, "accounts", StringComparison.Ordinal)
|
||||
? "Finishing the change to the accounts... (press Ctrl+C again to abort immediately)"
|
||||
: "Canceling... (press Ctrl+C again to abort immediately)";
|
||||
}
|
||||
@@ -37,4 +37,32 @@ internal sealed class CliErrorException : Exception
|
||||
/// </summary>
|
||||
public static CliErrorException FromEngine(InvalidOperationException ex)
|
||||
=> new(ex.Message, ex, ex is GitHubAccessException access ? ExitCodes.ForAccess(access.Kind) : ExitCodes.Fatal);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True for a failure of the accounts file or of the credential store: not a bug
|
||||
/// in gclo and not a usage mistake, but something on this machine that the
|
||||
/// message names (a file that cannot be written, an entry that cannot be read).
|
||||
/// </summary>
|
||||
public static bool IsStorageFailure(Exception ex)
|
||||
=> ex is IOException or UnauthorizedAccessException or gclo.ViewModels.AccountConsistencyException
|
||||
or System.ComponentModel.Win32Exception;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The error a storage failure ends a command with: fatal (2), or "retry later"
|
||||
/// (4) when another gclo process was changing the accounts at that moment. A
|
||||
/// failure of the credential store carries only a number; what Windows says that
|
||||
/// number means is added.
|
||||
/// </summary>
|
||||
public static CliErrorException FromStorage(Exception ex)
|
||||
{
|
||||
string message = ex is System.ComponentModel.Win32Exception native
|
||||
? $"{ex.Message} ({new System.ComponentModel.Win32Exception(native.NativeErrorCode).Message})"
|
||||
: ex.Message;
|
||||
return new CliErrorException(
|
||||
message, ex, ex is gclo.ViewModels.AccountsFileBusyException ? ExitCodes.Transient : ExitCodes.Fatal);
|
||||
}
|
||||
}
|
||||
@@ -58,11 +58,23 @@ internal sealed class OptionReader
|
||||
/// argument. A following token that itself starts with '--' is treated as a
|
||||
/// missing value (use '--name=--literal' to pass such a value literally).
|
||||
/// </summary>
|
||||
public string RequireValue()
|
||||
public string RequireValue() => RequireValue(mayBeEmpty: false);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The value for <see cref="Current"/>, which may be empty: '--note=' means the
|
||||
/// same as '--note ""', an empty text.
|
||||
/// </summary>
|
||||
public string RequireValueOrEmpty() => RequireValue(mayBeEmpty: true);
|
||||
|
||||
|
||||
|
||||
private string RequireValue(bool mayBeEmpty)
|
||||
{
|
||||
if(_inlineValue is not null)
|
||||
{
|
||||
if(_inlineValue.Length == 0)
|
||||
if(_inlineValue.Length == 0 && !mayBeEmpty)
|
||||
{
|
||||
throw new CliUsageException($"Option '{Current}' requires a value.");
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ internal static class OrgsCommand
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
|
||||
Justification = "Wires the real network lister and file log; delegates to the covered core.")]
|
||||
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
|
||||
=> RunAsync(args, new GitHubOrganizationLister(), new FileActivityLog(), cancellationToken);
|
||||
=> RunAsync(args, new GitHubOrganizationLister(), new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory), cancellationToken);
|
||||
|
||||
|
||||
|
||||
|
||||
+8
-5
@@ -72,15 +72,18 @@ internal static class Program
|
||||
using var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
if(!cts.IsCancellationRequested)
|
||||
string? command = args.Length > 0 ? args[0] : null;
|
||||
if(CancelKeyPolicy.HoldsBack(command, Terminal.IsReadingInput, cts.IsCancellationRequested))
|
||||
{
|
||||
// Finish gracefully: in-flight git operations stop, remaining
|
||||
// repositories are marked Canceled, and the summary still prints.
|
||||
// repositories are marked Canceled, and the summary still prints; a
|
||||
// change to the accounts runs to its end.
|
||||
e.Cancel = true;
|
||||
cts.Cancel();
|
||||
Terminal.Error("Canceling... (press Ctrl+C again to abort immediately)");
|
||||
Terminal.Error(CancelKeyPolicy.Notice(command));
|
||||
}
|
||||
// Second Ctrl+C: e.Cancel stays false and the process terminates.
|
||||
// Otherwise (a second Ctrl+C, a prompt waiting for a line, nothing that
|
||||
// could be cut off half-way): e.Cancel stays false and the process terminates.
|
||||
};
|
||||
|
||||
try
|
||||
@@ -127,7 +130,7 @@ internal static class Program
|
||||
string details = "";
|
||||
try
|
||||
{
|
||||
var log = new FileActivityLog();
|
||||
var log = new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory);
|
||||
log.Error($"unexpected error ({ex.GetType().FullName}): {ex.Message}", ex);
|
||||
details = log.CurrentLogFilePath;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ internal static class ReposCommand
|
||||
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
|
||||
Justification = "Wires the real network lister and file log; delegates to the covered core.")]
|
||||
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
|
||||
=> RunAsync(args, new GitHubRepositoryLister(), new FileActivityLog(), cancellationToken);
|
||||
=> RunAsync(args, new GitHubRepositoryLister(), new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory), cancellationToken);
|
||||
|
||||
|
||||
|
||||
|
||||
+61
-5
@@ -256,7 +256,7 @@ internal static class SyncCommand
|
||||
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
|
||||
=> RunAsync(
|
||||
args,
|
||||
new GitHubRepositoryLister(), new LibGit2GitClient(), AccountsCommand.Open, new FileActivityLog(),
|
||||
new GitHubRepositoryLister(), new LibGit2GitClient(), AccountsCommand.Open, new FileActivityLog(gclo.ViewModels.GcloPaths.LogDirectory),
|
||||
cancellationToken);
|
||||
|
||||
|
||||
@@ -296,8 +296,7 @@ internal static class SyncCommand
|
||||
ITokenVault? vault = null;
|
||||
if(accountName is not null)
|
||||
{
|
||||
(store, vault) = openAccounts(log);
|
||||
account = store.FindByName(accountName) ?? throw UnknownAccount(accountName, store);
|
||||
(account, store, vault) = OpenAccount(openAccounts, accountName, log);
|
||||
org ??= account.Organization;
|
||||
target ??= account.CreateOrgSubfolder ? Path.Combine(account.TargetRoot, org) : account.TargetRoot;
|
||||
parallel ??= account.MaxConcurrency;
|
||||
@@ -353,7 +352,7 @@ internal static class SyncCommand
|
||||
|
||||
if(dryRun)
|
||||
{
|
||||
return PrintDryRun(repositories, git, targetRoot, machineReadable, log);
|
||||
return EndDryRun(canceledWhileListing, repositories, git, targetRoot, machineReadable, log);
|
||||
}
|
||||
|
||||
OutputMode mode = jsonLines ? OutputMode.JsonLines
|
||||
@@ -425,6 +424,30 @@ internal static class SyncCommand
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// What a dry run ends with: the plan, or, when the listing was canceled, the
|
||||
/// fact that it was. Nothing was listed then, and an empty plan with exit code 0
|
||||
/// would say the organization has no repositories.
|
||||
/// </summary>
|
||||
private static int EndDryRun(
|
||||
bool canceledWhileListing,
|
||||
IReadOnlyList<RepoDescriptor> repositories,
|
||||
IGitClient git,
|
||||
string targetRoot,
|
||||
bool machineReadable,
|
||||
IActivityLog log)
|
||||
{
|
||||
if(canceledWhileListing)
|
||||
{
|
||||
Terminal.Error("Canceled.");
|
||||
log.Info("sync --dry-run: canceled while listing repositories.");
|
||||
return ExitCodes.Partial;
|
||||
}
|
||||
return PrintDryRun(repositories, git, targetRoot, machineReadable, log);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// '--dry-run': says what the run would do with each selected repository and
|
||||
/// touches nothing. Exit 0 even for an empty selection.
|
||||
@@ -457,6 +480,28 @@ internal static class SyncCommand
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Opens the accounts and finds the one named. A failure of the accounts file or
|
||||
/// of the credential store is a fatal error with its reason (or "retry later"
|
||||
/// when another gclo process holds the file), as it is for 'gclo accounts'.
|
||||
/// </summary>
|
||||
private static (Account Account, AccountsStore Store, ITokenVault Vault) OpenAccount(
|
||||
Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> openAccounts, string accountName, IActivityLog log)
|
||||
{
|
||||
try
|
||||
{
|
||||
(AccountsStore store, ITokenVault vault) = openAccounts(log);
|
||||
Account account = store.FindByName(accountName) ?? throw UnknownAccount(accountName, store);
|
||||
return (account, store, vault);
|
||||
}
|
||||
catch(Exception ex) when(CliErrorException.IsStorageFailure(ex))
|
||||
{
|
||||
throw CliErrorException.FromStorage(ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The token for this run. Any explicit token option wins; otherwise a saved
|
||||
/// account's token comes from Windows Credential Manager; plain runs fall back
|
||||
@@ -470,7 +515,18 @@ internal static class SyncCommand
|
||||
}
|
||||
|
||||
// vault is non-null whenever account is: both come from AccountsCommand.Open.
|
||||
string? token = new AccountTokenResolver(vault!).TryResolve(account);
|
||||
string? token;
|
||||
try
|
||||
{
|
||||
token = new AccountTokenResolver(vault!).TryResolve(account);
|
||||
}
|
||||
catch(Exception ex) when(CliErrorException.IsStorageFailure(ex))
|
||||
{
|
||||
// The credential store did not answer: say so with its reason (exit 2),
|
||||
// not as an unexpected error inside gclo.
|
||||
throw CliErrorException.FromStorage(ex);
|
||||
}
|
||||
|
||||
if(token is null)
|
||||
{
|
||||
string fix = account.UsesDefaultToken
|
||||
|
||||
@@ -29,6 +29,10 @@ namespace gclo.Cli;
|
||||
/// </remarks>
|
||||
internal static class Terminal
|
||||
{
|
||||
private static bool s_isReadingInput;
|
||||
|
||||
|
||||
|
||||
/// <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));
|
||||
|
||||
@@ -52,6 +56,19 @@ internal static class Terminal
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True while the process waits for a line on standard input (the token prompt).
|
||||
/// A Ctrl+C then ends the process: there is nothing to finish gracefully, and
|
||||
/// the read would otherwise go on waiting.
|
||||
/// </summary>
|
||||
public static bool IsReadingInput
|
||||
{
|
||||
get => Volatile.Read(ref s_isReadingInput);
|
||||
set => Volatile.Write(ref s_isReadingInput, value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Ends the current line on standard error.</summary>
|
||||
public static void ErrorLineBreak() => Console.Error.WriteLine();
|
||||
|
||||
|
||||
@@ -153,9 +153,18 @@ internal sealed class TokenOptions
|
||||
|
||||
private static string FromStdin()
|
||||
{
|
||||
string? line = Console.IsInputRedirected
|
||||
? Console.In.ReadLine()?.Trim()
|
||||
: ReadInteractiveToken();
|
||||
string? line;
|
||||
Terminal.IsReadingInput = true;
|
||||
try
|
||||
{
|
||||
line = Console.IsInputRedirected
|
||||
? Console.In.ReadLine()?.Trim()
|
||||
: ReadInteractiveToken();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Terminal.IsReadingInput = false;
|
||||
}
|
||||
|
||||
if(string.IsNullOrEmpty(line))
|
||||
{
|
||||
|
||||
@@ -57,4 +57,24 @@ public sealed class GcloPathsTests
|
||||
Assert.False(
|
||||
GcloPaths.DefaultDataRoot.StartsWith(installRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The log folder follows the data folder, so the app and the CLI log side by side wherever that is (#159).</summary>
|
||||
[Fact]
|
||||
public void LogDirectory_IsLogsUnderTheDataRoot_AlsoWhenTheDataRootIsMoved()
|
||||
{
|
||||
string moved = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
|
||||
Environment.SetEnvironmentVariable("GCLO_DATA_DIR", moved);
|
||||
try
|
||||
{
|
||||
Assert.Equal(Path.Combine(moved, "logs"), GcloPaths.LogDirectory);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null);
|
||||
}
|
||||
|
||||
Assert.Equal(Path.Combine(GcloPaths.DefaultDataRoot, "logs"), GcloPaths.LogDirectory);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,14 @@ public static class GcloPaths
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Where the activity log is written: "logs" under <see cref="DataRoot"/>, for the
|
||||
/// desktop app and the CLI alike, so both follow GCLO_DATA_DIR together.
|
||||
/// </summary>
|
||||
public static string LogDirectory => Path.Combine(DataRoot, "logs");
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The installed edition: "" for the stable app (Velopack app id <c>gclo</c>) and
|
||||
/// "dev" for the dev-channel app (<c>gclo-dev</c>), which installs beside the
|
||||
|
||||
@@ -118,7 +118,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
_orgLister = orgLister ?? new GitHubOrganizationLister();
|
||||
_progressFactory = progressFactory ?? (handler => new Progress<RepoProgress>(handler));
|
||||
_orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600);
|
||||
_log = log ?? new FileActivityLog();
|
||||
_log = log ?? new FileActivityLog(GcloPaths.LogDirectory);
|
||||
_account = account;
|
||||
_accountsStore = accountsStore;
|
||||
Organization = "";
|
||||
|
||||
Reference in new issue
Block a user