fix(sync): pull only a clone of the listed repository, over the listed URL #140
No files matched your search
@@ -186,7 +186,7 @@ Full reference — all options, output streams, Ctrl+C behavior, and more exampl
|
||||
|
||||
## How it works
|
||||
|
||||
The engine lists every repository through the GitHub REST API (Octokit, paginated 100 per page), then runs the set through `Parallel.ForEachAsync` bounded by the parallelism setting. Git operations use LibGit2Sharp: missing repos are cloned with object-transfer progress; existing repos get a fetch from `origin` followed by a **fast-forward-only** merge — if a local branch has diverged from its upstream, the repo is reported Failed rather than auto-merged, so gclo never manufactures merge commits. Archived repositories are included and sync normally. Every repo ends in exactly one terminal state — Done, Failed (with the error), or Canceled — and per-repo exceptions are contained so the rest of the run continues. The PAT is the only credential; on the git side it is sent as HTTPS basic credentials with username `x-access-token`.
|
||||
The engine lists every repository through the GitHub REST API (Octokit, paginated 100 per page), then runs the set through `Parallel.ForEachAsync` bounded by the parallelism setting. Git operations use LibGit2Sharp: missing repos are cloned with object-transfer progress; existing repos get a fetch from `origin` followed by a **fast-forward-only** merge — if a local branch has diverged from its upstream, the repo is reported Failed rather than auto-merged, so gclo never manufactures merge commits. An existing folder is only updated when its `origin` is the repository gclo listed: a same-named folder that is a clone of something else (a fork, another owner's repository, another host) is reported Failed and left untouched. A repository that was renamed or moved to another owner looks the same to gclo; the failure message gives the `git remote set-url` command that points the folder at its new URL. Archived repositories are included and sync normally. Every repo ends in exactly one terminal state — Done, Failed (with the error), or Canceled — and per-repo exceptions are contained so the rest of the run continues. The PAT is the only credential; on the git side it is sent as HTTPS basic credentials with username `x-access-token`, and only to the host the repository was listed on.
|
||||
|
||||
## Build from source
|
||||
|
||||
|
||||
@@ -86,15 +86,16 @@ flowchart LR
|
||||
| T1 | Update feed | Tampering | Attacker serves a malicious update | Low | Critical | HTTPS to this repository's Releases only; Velopack hash verification; immutable releases; provenance and SBOM attestations; Scorecard; Authenticode pending (EX-0002) | Mitigated (signing pending) |
|
||||
| T2 | Package managers / downloads | Tampering | Modified installer or CLI zip on a mirror | Low | High | `SHA256SUMS`; Scoop and Chocolatey manifests pin the hash; "Verifying a release" instructions | Mitigated |
|
||||
| T3 | Git remotes | Tampering / EoP | Repository with Windows-invalid or traversal paths (`..`, reserved names, case collisions) writes outside or corrupts the target | Medium | High | Pre-checkout validation of every incoming path; `RepositoryPathResolver` containment; recovery by rename/skip or clone in WSL; `core.longpaths` | Mitigated |
|
||||
| T4 | Git remotes | Tampering | Hostile `.git` config or hooks in a pre-existing target repository | Low | High | gclo runs no hooks (libgit2, no shell); only fetch and fast-forward; `gclo.checkoutpending` marks incomplete trees | Mitigated |
|
||||
| T4 | Git remotes | Tampering | Hostile `.git` config or hooks in a pre-existing target repository | Low | High | gclo runs no hooks (libgit2, no shell); only fetch and fast-forward; `gclo.checkoutpending` marks incomplete trees; a folder is fetched only when its `origin` names the listed repository, and the remote contacted is the listed URL, not origin's spelling of it; a `url.<base>.insteadOf` rewrite in the folder's own configuration is refused, and so is its `core.sshCommand` when the fetch is not HTTPS (`RemoteUrl`, #139). The user's global git configuration still applies, as it does to git itself | Mitigated for the remote and the transport program; other `.git` content of an existing folder is still trusted |
|
||||
| T5 | PAT handling | Information disclosure | Token leaks to disk, logs, process listings, or UI | Medium | Critical | Memory-only in Quick Sync; Credential Manager otherwise; accounts may reference the default token (`TokenSource`), resolved at use time through one resolver and never copied; logs record lengths; no `--token`; WSL via env only; secret scanning + gitleaks + CodeQL in CI | Mitigated |
|
||||
| T6 | Credential Manager | Information disclosure | Another process as the same user reads the stored token | Medium | High | Documented boundary (SECURITY.md); vault scoped per data directory so test or portable instances never see the default profile's tokens | Accepted (OS boundary) |
|
||||
| T7 | GitHub API | Denial of service | Rate limits or abuse detection stop a sync | Medium | Low | Secondary-rate-limit and abuse handling with backoff; per-repo failure isolation; cancellation | Mitigated |
|
||||
| T8 | GitHub API / remotes | Spoofing | A MITM impersonates GitHub | Low | Critical | HTTPS with system trust store (Octokit, libgit2); no HTTP fallback | Mitigated |
|
||||
| T9 | WSL recovery | Elevation of privilege | Hostile org/repo names injected into the shell script | Low | High | Script fed on stdin with positional parameters; names validated as repository names first; no string interpolation into the script | Mitigated |
|
||||
| T8 | GitHub API / remotes | Spoofing | A MITM impersonates GitHub | Low | Critical | HTTPS with system trust store (Octokit, libgit2); no HTTP fallback: an existing folder whose origin is `http://` or SSH is fetched through the listed HTTPS URL instead (unless the user's own global configuration rewrites that URL) | Mitigated |
|
||||
| T9 | WSL recovery | Elevation of privilege | Hostile org/repo names injected into the shell script, or used to leave `~/gclo` | Low | High | Script fed on stdin with positional parameters; organization and repository each validated as one plain name inside `WslCloner` (and the organization again before any listing request); the URL must be HTTPS and is passed after `--`; no string interpolation into the script | Mitigated |
|
||||
| T10 | Logs and crash output | Information disclosure | Exception text carries a token or private path | Low | Medium | Crash net logs messages only; log review in incident handling; tokens never in exception messages (tests) | Mitigated |
|
||||
| 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 |
|
||||
| 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
|
||||
@@ -115,3 +116,4 @@ flowchart LR
|
||||
| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | Local data directory moved to `%LOCALAPPDATA%\KofTwentyTwo\gclo`: the Velopack install root is deleted by Setup.exe, which destroyed accounts.json and settings.json (#91) |
|
||||
| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | T2/T11: releases are Authenticode-signed with Azure Artifact Signing over GitHub OIDC (no signing secret; federated credential bound to this repository's `release` environment; signer role scoped to the certificate profile) and the published assets are verified with `signtool verify /pa /all` (#57, ADR 0008) |
|
||||
| 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) |
|
||||
@@ -147,7 +147,7 @@ internal sealed class FakeGit : IGitClient
|
||||
|
||||
|
||||
|
||||
public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
|
||||
public Task FetchAndPullAsync(string url, string path, string token, CancellationToken cancellationToken)
|
||||
=> OnPull(path, token, cancellationToken);
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed record CloneCall(string Url, string LocalPath, string Token);
|
||||
|
||||
|
||||
|
||||
public sealed record PullCall(string LocalPath, string Token);
|
||||
public sealed record PullCall(string LocalPath, string Token, string Url);
|
||||
|
||||
|
||||
|
||||
@@ -126,9 +126,9 @@ public sealed class FakeGitClient : IGitClient
|
||||
|
||||
|
||||
|
||||
public async Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
|
||||
public async Task FetchAndPullAsync(string url, string path, string token, CancellationToken cancellationToken)
|
||||
{
|
||||
_pullCalls.Enqueue(new PullCall(path, token));
|
||||
_pullCalls.Enqueue(new PullCall(path, token, url));
|
||||
await FetchAndPullHandler(path, token, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using LibGit2Sharp;
|
||||
using LibGit2Sharp.Handlers;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The credential callback <see cref="LibGit2GitClient"/> hands to libgit2 (#139). The
|
||||
/// offline fixtures are local paths, which never ask for credentials, so the callback
|
||||
/// is called here the way libgit2 calls it: with the URL of the remote that asks.
|
||||
/// </summary>
|
||||
public sealed class GitCredentialsTests
|
||||
{
|
||||
private const string Token = "ghp_secret_token_value_1234567890";
|
||||
|
||||
private const string Listed = "https://github.com/acme/repo.git";
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://github.com/acme/repo.git")]
|
||||
[InlineData("https://GitHub.com/acme/repo.git")]
|
||||
public void ListedHostOverHttps_GetsTheTokenAsThePassword(string asking)
|
||||
{
|
||||
CredentialsHandler provider = LibGit2GitClient.MakeCredentialsProvider(Token, Listed);
|
||||
|
||||
UsernamePasswordCredentials credentials = Assert.IsType<UsernamePasswordCredentials>(provider(asking, null, SupportedCredentialTypes.UsernamePassword));
|
||||
|
||||
Assert.Equal("x-access-token", credentials.Username);
|
||||
Assert.Equal(Token, credentials.Password);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://evil.example/acme/repo.git")]
|
||||
[InlineData("https://github.com.evil.example/acme/repo.git")]
|
||||
[InlineData("https://github.com:8443/acme/repo.git")]
|
||||
[InlineData("http://github.com/acme/repo.git")]
|
||||
[InlineData("ssh://git@github.com/acme/repo.git")]
|
||||
[InlineData("git@github.com:acme/repo.git")]
|
||||
public void AnyOtherRemote_GetsNothing_AndTheErrorDoesNotCarryTheToken(string asking)
|
||||
{
|
||||
CredentialsHandler provider = LibGit2GitClient.MakeCredentialsProvider(Token, Listed);
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
|
||||
() => provider(asking, null, SupportedCredentialTypes.UsernamePassword));
|
||||
|
||||
Assert.Contains("Nothing was sent", ex.Message);
|
||||
Assert.DoesNotContain(Token, ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ListedUrlThatIsNotHttps_NobodyGetsTheToken()
|
||||
{
|
||||
CredentialsHandler provider = LibGit2GitClient.MakeCredentialsProvider(Token, "http://git.example.com/acme/repo.git");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => provider("http://git.example.com/acme/repo.git", null, SupportedCredentialTypes.UsernamePassword));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ErrorForAnotherRemote_DoesNotShowCredentialsInItsUrl()
|
||||
{
|
||||
CredentialsHandler provider = LibGit2GitClient.MakeCredentialsProvider(Token, Listed);
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
|
||||
() => provider("https://someone:hunter2@evil.example/acme/repo.git", null, SupportedCredentialTypes.UsernamePassword));
|
||||
|
||||
Assert.Contains("https://evil.example/acme/repo.git", ex.Message);
|
||||
Assert.DoesNotContain("hunter2", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void ThirdRequest_MeansTheTokenWasRejected_NotAnEndlessReplay()
|
||||
{
|
||||
CredentialsHandler provider = LibGit2GitClient.MakeCredentialsProvider(Token, Listed);
|
||||
provider(Listed, null, SupportedCredentialTypes.UsernamePassword);
|
||||
provider(Listed, null, SupportedCredentialTypes.UsernamePassword);
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
|
||||
() => provider(Listed, null, SupportedCredentialTypes.UsernamePassword));
|
||||
|
||||
Assert.Contains("rejected the token", ex.Message);
|
||||
Assert.DoesNotContain(Token, ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void RefusedRequests_DoNotCountTowardTheReplayLimit()
|
||||
{
|
||||
CredentialsHandler provider = LibGit2GitClient.MakeCredentialsProvider(Token, Listed);
|
||||
for(int i = 0; i < 5; i++)
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => provider("https://evil.example/x.git", null, SupportedCredentialTypes.UsernamePassword));
|
||||
}
|
||||
|
||||
Assert.IsType<UsernamePasswordCredentials>(provider(Listed, null, SupportedCredentialTypes.UsernamePassword));
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,28 @@ public sealed class GitHubListerTests
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("../..")]
|
||||
[InlineData("..")]
|
||||
[InlineData("acme/other")]
|
||||
[InlineData(@"acme\other")]
|
||||
[InlineData("C:acme")]
|
||||
public async Task Repos_NameThatIsNotOnePlainName_IsRefusedBeforeAnyRequest(string organization)
|
||||
{
|
||||
bool asked = false;
|
||||
_gateway.OrgPages = (_, _) => { asked = true; return Task.FromResult<IReadOnlyList<GitHubRepo>>([]); };
|
||||
_gateway.AccountKind = _ => { asked = true; return Task.FromResult(GitHubAccountKind.User); };
|
||||
|
||||
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
|
||||
() => RepoLister().ListOrganizationRepositoriesAsync(organization, "tok-1234567890"));
|
||||
|
||||
Assert.Equal(GitHubAccessKind.NotFound, ex.Kind);
|
||||
Assert.Contains($"'{organization}' is not an organization or account name", ex.Message);
|
||||
Assert.False(asked, "no request may be made for a name that is not a name");
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Repos_OrgNotFound_ButItIsAnOrganizationTheTokenCannotSee_FailsInsteadOfListingPublicRepos()
|
||||
{
|
||||
|
||||
@@ -51,6 +51,22 @@ internal static class GitTestHelpers
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Pulls <paramref name="workdir"/> the way a sync does for a folder that is a clone
|
||||
/// of the listed repository: with the URL of its own origin.
|
||||
/// </summary>
|
||||
public static Task PullAsync(this LibGit2GitClient client, string workdir, string token, CancellationToken cancellationToken)
|
||||
{
|
||||
string url;
|
||||
using(var repo = new Repository(workdir))
|
||||
{
|
||||
url = repo.Network.Remotes["origin"].Url;
|
||||
}
|
||||
return client.FetchAndPullAsync(url, workdir, token, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>SHA of the commit at HEAD's tip.</summary>
|
||||
public static string HeadSha(string workdir)
|
||||
{
|
||||
|
||||
@@ -165,14 +165,14 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
_client.AfterFetchForTesting = cts.Cancel;
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => _client.FetchAndPullAsync(target, Token, cts.Token));
|
||||
() => _client.PullAsync(target, Token, cts.Token));
|
||||
|
||||
Assert.Equal(tipBefore, HeadSha(target));
|
||||
Assert.False(File.Exists(Path.Combine(target, "update.txt")));
|
||||
|
||||
// The fetch itself landed: a later uncanceled pull is a plain fast-forward.
|
||||
_client.AfterFetchForTesting = null;
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
Assert.True(File.Exists(Path.Combine(target, "update.txt")));
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
|
||||
string newSha = CommitFile(source, "update.txt", "new content", "second commit");
|
||||
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
|
||||
Assert.Equal(newSha, HeadSha(target));
|
||||
Assert.Equal("new content", File.ReadAllText(Path.Combine(target, "update.txt")));
|
||||
@@ -265,7 +265,7 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
|
||||
string tipBefore = HeadSha(target);
|
||||
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
|
||||
Assert.Equal(tipBefore, HeadSha(target));
|
||||
}
|
||||
@@ -287,7 +287,7 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
SetCheckoutPending(target, true);
|
||||
Assert.True(_client.IsValidRepository(target));
|
||||
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
|
||||
Assert.Equal("hello from source", File.ReadAllText(Path.Combine(target, "readme.txt")));
|
||||
Assert.Equal(tipBefore, HeadSha(target));
|
||||
@@ -306,7 +306,7 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
SetCheckoutPending(target, true);
|
||||
string newSha = CommitFile(source, "update.txt", "new content", "second commit");
|
||||
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
|
||||
Assert.Equal(newSha, HeadSha(target));
|
||||
Assert.Equal("hello from source", File.ReadAllText(Path.Combine(target, "readme.txt")));
|
||||
@@ -335,7 +335,7 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
}
|
||||
|
||||
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
|
||||
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
|
||||
() => _client.PullAsync(target, Token, CancellationToken.None));
|
||||
|
||||
Assert.Equal("bad:name.txt", Assert.Single(ex.Paths).RepoPath);
|
||||
Assert.True(IsCheckoutPending(target), "still incomplete; the marker must survive the failure");
|
||||
@@ -356,7 +356,7 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
CommitFile(target, "from-local.txt", "local change", "local commit");
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
|
||||
() => _client.PullAsync(target, Token, CancellationToken.None));
|
||||
|
||||
// Actionable, not libgit2's "cannot fast-forward": names the branch, says what
|
||||
// gclo refuses to do, and gives the three ways out (#30).
|
||||
@@ -382,7 +382,7 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
}
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
|
||||
() => _client.PullAsync(target, Token, CancellationToken.None));
|
||||
|
||||
Assert.Contains("detached", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Check out a branch", ex.Message);
|
||||
@@ -408,7 +408,7 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
}
|
||||
string newSha = CommitFile(source, "after-rename.txt", "after", "commit on the renamed branch");
|
||||
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
|
||||
using var after = new Repository(target);
|
||||
Assert.Equal("renamed-main", after.Head.FriendlyName);
|
||||
@@ -419,7 +419,7 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
Assert.Null(after.Branches["origin/" + oldName]); // and so is its pruned tracking ref
|
||||
|
||||
// The next pull is an ordinary up-to-date pull on the new branch.
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
Assert.Equal(newSha, HeadSha(target));
|
||||
}
|
||||
|
||||
@@ -439,7 +439,7 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
}
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
|
||||
() => _client.PullAsync(target, Token, CancellationToken.None));
|
||||
|
||||
Assert.Contains("local commits", ex.Message);
|
||||
Assert.Contains("never discards local work", ex.Message);
|
||||
@@ -467,7 +467,7 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
|
||||
string remoteSha = CommitFile(source, "later.txt", "later content", "remote commit");
|
||||
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
|
||||
using var after = new Repository(target);
|
||||
Assert.Equal("local-only", after.Head.FriendlyName);
|
||||
@@ -501,7 +501,7 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
repo.Network.Push(repo.Network.Remotes["origin"], repo.Head.CanonicalName, new PushOptions());
|
||||
}
|
||||
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
|
||||
using var after = new Repository(target);
|
||||
Assert.False(after.Info.IsHeadUnborn);
|
||||
@@ -522,7 +522,7 @@ public sealed class LibGit2GitClientTests : IDisposable
|
||||
await _client.CloneAsync(origin, target, Token, null, CancellationToken.None);
|
||||
|
||||
// Nothing to pull yet: there is no branch to materialize, and that is not an error.
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
|
||||
using var after = new Repository(target);
|
||||
Assert.True(after.Info.IsHeadUnborn);
|
||||
|
||||
@@ -62,7 +62,11 @@ public sealed class OrgSyncEngineTests : IDisposable
|
||||
}
|
||||
foreach(PullCall call in _git.PullCalls)
|
||||
{
|
||||
Assert.Equal(Path.Combine(_targetRoot, Path.GetFileName(call.LocalPath)!), call.LocalPath);
|
||||
string name = Path.GetFileName(call.LocalPath)!;
|
||||
Assert.Equal(Path.Combine(_targetRoot, name), call.LocalPath);
|
||||
// The listed clone URL travels with the pull, so the git client can refuse a
|
||||
// folder that is a clone of something else (#139).
|
||||
Assert.Equal($"https://example.test/acme/{name}.git", call.Url);
|
||||
Assert.Equal("test-token", call.Token);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using LibGit2Sharp;
|
||||
using static gclo.Engine.Tests.GitTestHelpers;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A pull is given the clone URL the repository was listed with, and only pulls a
|
||||
/// folder that is a clone of that repository (#139). Real repositories on the local
|
||||
/// file system, like <see cref="LibGit2GitClientTests"/>.
|
||||
/// </summary>
|
||||
public sealed class OriginVerificationTests : IDisposable
|
||||
{
|
||||
private const string Token = "test-token";
|
||||
|
||||
private readonly string _root =
|
||||
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
private readonly LibGit2GitClient _client = new();
|
||||
|
||||
|
||||
|
||||
public OriginVerificationTests()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public void Dispose() => TryDeleteDirectory(_root);
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_FolderIsACloneOfAnotherRepository_FailsWithoutFetching()
|
||||
{
|
||||
string listed = CreateSourceRepo("listed");
|
||||
string unrelated = CreateSourceRepo("unrelated");
|
||||
string target = Path.Combine(_root, "clone");
|
||||
await _client.CloneAsync(unrelated, target, Token, null, CancellationToken.None);
|
||||
string tipBefore = HeadSha(target);
|
||||
string trackingBefore = TrackingTip(target);
|
||||
// Something to fetch from the folder's own origin, had it been contacted.
|
||||
CommitFile(unrelated, "more.txt", "more", "second commit");
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _client.FetchAndPullAsync(listed, target, Token, CancellationToken.None));
|
||||
|
||||
Assert.Contains("clone of a different repository", ex.Message);
|
||||
Assert.Contains(unrelated, ex.Message);
|
||||
Assert.Contains(listed, ex.Message);
|
||||
// A renamed or transferred repository looks exactly like this; the message says
|
||||
// how to point the folder at the new URL.
|
||||
Assert.Contains($"git -C \"{target}\" remote set-url origin {listed}", ex.Message);
|
||||
Assert.Equal(tipBefore, HeadSha(target));
|
||||
Assert.Equal(trackingBefore, TrackingTip(target));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_OriginSpelledDifferently_IsTheSameRepository_AndIsPulledFromTheListedUrl()
|
||||
{
|
||||
string source = CreateSourceRepo("source");
|
||||
string target = Path.Combine(_root, "clone");
|
||||
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
// The same folder, written as a file URI: what counts is which repository
|
||||
// the origin names, not how the URL is spelled.
|
||||
repo.Network.Remotes.Update("origin", r => r.Url = new Uri(source).AbsoluteUri);
|
||||
}
|
||||
string newSha = CommitFile(source, "update.txt", "new content", "second commit");
|
||||
|
||||
await _client.FetchAndPullAsync(source, target, Token, CancellationToken.None);
|
||||
|
||||
Assert.Equal(newSha, HeadSha(target));
|
||||
Assert.Equal(newSha, TrackingTip(target));
|
||||
Assert.Equal("new content", File.ReadAllText(Path.Combine(target, "update.txt")));
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
// Origin keeps the spelling the user gave it, and the remote used for the
|
||||
// fetch is gone.
|
||||
Assert.Equal(new Uri(source).AbsoluteUri, repo.Network.Remotes["origin"].Url);
|
||||
Assert.Equal("origin", Assert.Single(repo.Network.Remotes).Name);
|
||||
}
|
||||
// What was contacted is the listed URL, not origin's spelling of it: FETCH_HEAD
|
||||
// records the URL of the remote a fetch used.
|
||||
string fetchHead = File.ReadAllText(Path.Combine(target, ".git", "FETCH_HEAD"));
|
||||
Assert.Contains(source.Replace('\\', '/'), fetchHead.Replace('\\', '/'));
|
||||
Assert.DoesNotContain("file://", fetchHead);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_OriginSpelledDifferently_StillFollowsTags_AndPrunesDeletedBranches()
|
||||
{
|
||||
string source = CreateSourceRepo("source");
|
||||
using(var repo = new Repository(source))
|
||||
{
|
||||
repo.CreateBranch("topic");
|
||||
}
|
||||
string target = Path.Combine(_root, "clone");
|
||||
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
repo.Network.Remotes.Update("origin", r => r.Url = new Uri(source).AbsoluteUri);
|
||||
}
|
||||
string newSha = CommitFile(source, "update.txt", "new content", "second commit");
|
||||
using(var repo = new Repository(source))
|
||||
{
|
||||
repo.ApplyTag("v1.0.0");
|
||||
repo.Branches.Remove("topic");
|
||||
}
|
||||
|
||||
await _client.FetchAndPullAsync(source, target, Token, CancellationToken.None);
|
||||
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
Assert.Equal(newSha, repo.Head.Tip.Sha);
|
||||
Assert.NotNull(repo.Tags["v1.0.0"]);
|
||||
Assert.Null(repo.Branches["origin/topic"]);
|
||||
// Removing the fetch's remote must not take origin's tracking refs with it.
|
||||
Assert.NotNull(repo.Head.TrackedBranch?.Tip);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_OriginSpelledDifferently_ARemoteLeftByAKilledRun_IsReplaced_NotFetched()
|
||||
{
|
||||
string source = CreateSourceRepo("source");
|
||||
string target = Path.Combine(_root, "clone");
|
||||
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
repo.Network.Remotes.Update("origin", r => r.Url = new Uri(source).AbsoluteUri);
|
||||
// What a process killed mid-fetch leaves behind, pointing somewhere stale.
|
||||
repo.Network.Remotes.Add("gclo-sync", Path.Combine(_root, "gone"));
|
||||
repo.Config.Set("remote.gclo-sync.gclo-temporary", true, ConfigurationLevel.Local);
|
||||
}
|
||||
string newSha = CommitFile(source, "update.txt", "new content", "second commit");
|
||||
|
||||
await _client.FetchAndPullAsync(source, target, Token, CancellationToken.None);
|
||||
|
||||
Assert.Equal(newSha, HeadSha(target));
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
Assert.Equal("origin", Assert.Single(repo.Network.Remotes).Name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_StillFollowsTags_AndPrunesDeletedBranches()
|
||||
{
|
||||
// The fetch names the listed URL rather than the remote "origin"; it must keep
|
||||
// doing what a fetch of origin does: bring tags on fetched commits and drop
|
||||
// tracking refs of branches origin no longer has.
|
||||
string source = CreateSourceRepo("source");
|
||||
using(var repo = new Repository(source))
|
||||
{
|
||||
repo.CreateBranch("topic");
|
||||
}
|
||||
string target = Path.Combine(_root, "clone");
|
||||
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
Assert.NotNull(repo.Branches["origin/topic"]);
|
||||
}
|
||||
CommitFile(source, "update.txt", "new content", "second commit");
|
||||
using(var repo = new Repository(source))
|
||||
{
|
||||
repo.ApplyTag("v1.0.0");
|
||||
repo.Branches.Remove("topic");
|
||||
}
|
||||
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
Assert.NotNull(repo.Tags["v1.0.0"]);
|
||||
Assert.Null(repo.Branches["origin/topic"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_MismatchMessage_NeverShowsCredentialsStoredInTheOrigin()
|
||||
{
|
||||
string listed = CreateSourceRepo("listed");
|
||||
string target = Path.Combine(_root, "clone");
|
||||
await _client.CloneAsync(listed, target, Token, null, CancellationToken.None);
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
repo.Network.Remotes.Update("origin", r => r.Url = "https://someone:hunter2@example.test/acme/other.git");
|
||||
}
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _client.FetchAndPullAsync(listed, target, Token, CancellationToken.None));
|
||||
|
||||
Assert.Contains("https://example.test/acme/other.git", ex.Message);
|
||||
Assert.DoesNotContain("hunter2", ex.Message);
|
||||
Assert.DoesNotContain("someone", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_ARemoteLeftByAKilledRun_IsAlsoRemoved_WhenOriginIsTheListedUrl()
|
||||
{
|
||||
string source = CreateSourceRepo("source");
|
||||
string target = Path.Combine(_root, "clone");
|
||||
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
repo.Network.Remotes.Add("gclo-sync", Path.Combine(_root, "gone"));
|
||||
repo.Config.Set("remote.gclo-sync.gclo-temporary", true, ConfigurationLevel.Local);
|
||||
}
|
||||
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
|
||||
using(var repo2 = new Repository(target))
|
||||
{
|
||||
Assert.Equal("origin", Assert.Single(repo2.Network.Remotes).Name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_TheUsersOwnRemoteNamedGcloSync_IsNeverRemoved()
|
||||
{
|
||||
string source = CreateSourceRepo("source");
|
||||
string elsewhere = CreateSourceRepo("elsewhere");
|
||||
string target = Path.Combine(_root, "clone");
|
||||
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
repo.Network.Remotes.Add("gclo-sync", elsewhere); // theirs: no gclo marker
|
||||
}
|
||||
|
||||
// Origin is the listed URL: the name is not needed, and their remote stays.
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
Assert.Equal(elsewhere, repo.Network.Remotes["gclo-sync"].Url);
|
||||
repo.Network.Remotes.Update("origin", r => r.Url = new Uri(source).AbsoluteUri);
|
||||
}
|
||||
|
||||
// Origin is spelled differently: the name is needed, so the pull says so instead
|
||||
// of taking it over.
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _client.FetchAndPullAsync(source, target, Token, CancellationToken.None));
|
||||
|
||||
Assert.Contains("remote of its own named 'gclo-sync'", ex.Message);
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
Assert.Equal(elsewhere, repo.Network.Remotes["gclo-sync"].Url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_FolderConfigurationRewritesUrls_IsRefused_BeforeAnythingIsFetched()
|
||||
{
|
||||
string source = CreateSourceRepo("source");
|
||||
string other = CreateSourceRepo("other");
|
||||
string target = Path.Combine(_root, "clone");
|
||||
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
|
||||
string trackingBefore = TrackingTip(target);
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
// url.<other>.insteadOf = <source>: every fetch of the listed URL would
|
||||
// really contact the other repository.
|
||||
repo.Config.Set($"url.{other}.insteadof", source, ConfigurationLevel.Local);
|
||||
}
|
||||
CommitFile(source, "update.txt", "new content", "second commit");
|
||||
CommitFile(other, "planted.txt", "not what was listed", "planted commit");
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _client.FetchAndPullAsync(source, target, Token, CancellationToken.None));
|
||||
|
||||
Assert.Contains("url.<base>.insteadOf", ex.Message);
|
||||
Assert.Equal(trackingBefore, TrackingTip(target));
|
||||
Assert.False(File.Exists(Path.Combine(target, "planted.txt")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_FolderConfigurationNamesAnSshProgram_IsRefused_WhenTheFetchIsNotHttps()
|
||||
{
|
||||
// The fixtures are local paths, which stand in here for "not HTTPS": the case
|
||||
// where the user's global configuration sends the fetch over SSH.
|
||||
string source = CreateSourceRepo("source");
|
||||
string target = Path.Combine(_root, "clone");
|
||||
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
|
||||
string trackingBefore = TrackingTip(target);
|
||||
using(var repo = new Repository(target))
|
||||
{
|
||||
repo.Config.Set("core.sshCommand", "calc.exe", ConfigurationLevel.Local);
|
||||
}
|
||||
CommitFile(source, "update.txt", "new content", "second commit");
|
||||
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _client.PullAsync(target, Token, CancellationToken.None));
|
||||
|
||||
Assert.Contains("core.sshCommand", ex.Message);
|
||||
Assert.Equal(trackingBefore, TrackingTip(target));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
private string CreateSourceRepo(string name)
|
||||
{
|
||||
string path = Path.Combine(_root, name);
|
||||
Repository.Init(path);
|
||||
CommitFile(path, "readme.txt", "hello from " + name, "initial commit");
|
||||
return path;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static string CommitFile(string workdir, string fileName, string content, string message)
|
||||
{
|
||||
using var repo = new Repository(workdir);
|
||||
File.WriteAllText(Path.Combine(workdir, fileName), content);
|
||||
Commands.Stage(repo, fileName);
|
||||
Signature signature = MakeSignature();
|
||||
return repo.Commit(message, signature, signature).Sha;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Tip of the remote-tracking ref of the clone's current branch.</summary>
|
||||
private static string TrackingTip(string workdir)
|
||||
{
|
||||
using var repo = new Repository(workdir);
|
||||
return repo.Head.TrackedBranch.Tip.Sha;
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ public sealed class PathRecoveryTests : IDisposable
|
||||
// the pull fails typed, exactly like the original clone did.
|
||||
AppendForgedCommit(source, ("worse:new.txt", "new content"));
|
||||
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
|
||||
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
|
||||
() => _client.PullAsync(target, Token, CancellationToken.None));
|
||||
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "worse:new.txt", StringComparison.Ordinal));
|
||||
|
||||
// Applying a recovery that covers ONLY the new path merges with the stored one:
|
||||
@@ -245,7 +245,7 @@ public sealed class PathRecoveryTests : IDisposable
|
||||
Assert.True(File.Exists(Path.Combine(target, "gone.txt")));
|
||||
|
||||
ReplaceForgedEntry(source, remove: "gone.txt", add: ("new/nested.txt", "new"));
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
|
||||
// A real checkout removes what left the tree; the materialization must too.
|
||||
Assert.False(File.Exists(Path.Combine(target, "gone.txt")));
|
||||
@@ -254,7 +254,7 @@ public sealed class PathRecoveryTests : IDisposable
|
||||
|
||||
// And a directory emptied by a later deletion is pruned too.
|
||||
ReplaceForgedEntry(source, remove: "new/nested.txt", add: ("other.txt", "o"));
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
Assert.False(Directory.Exists(Path.Combine(target, "new")));
|
||||
}
|
||||
|
||||
@@ -269,12 +269,12 @@ public sealed class PathRecoveryTests : IDisposable
|
||||
|
||||
// A local edit is the witness: an unchanged tip must leave the tree alone.
|
||||
File.WriteAllText(Path.Combine(target, "keep.txt"), "locally edited");
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
Assert.Equal("locally edited", File.ReadAllText(Path.Combine(target, "keep.txt")));
|
||||
|
||||
// A new upstream commit re-materializes through the mapping as before.
|
||||
ReplaceForgedEntry(source, remove: "keep.txt", add: ("keep.txt", "upstream edit"));
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
Assert.Equal("upstream edit", File.ReadAllText(Path.Combine(target, "keep.txt")));
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ public sealed class PathRecoveryTests : IDisposable
|
||||
File.WriteAllText(file, "{ \"SegmentRenames\": { truncated");
|
||||
|
||||
InvalidOperationException pullError = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
|
||||
() => _client.PullAsync(target, Token, CancellationToken.None));
|
||||
Assert.Contains("corrupt", pullError.Message);
|
||||
Assert.Contains(file, pullError.Message);
|
||||
Assert.Contains("Delete it", pullError.Message);
|
||||
@@ -302,7 +302,7 @@ public sealed class PathRecoveryTests : IDisposable
|
||||
// "null" is a corrupt document too.
|
||||
File.WriteAllText(file, "null");
|
||||
InvalidOperationException nullError = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
|
||||
() => _client.PullAsync(target, Token, CancellationToken.None));
|
||||
Assert.Contains("empty", nullError.Message);
|
||||
}
|
||||
|
||||
@@ -337,7 +337,7 @@ public sealed class PathRecoveryTests : IDisposable
|
||||
|
||||
// The repo must not silently report success with an empty working tree.
|
||||
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
|
||||
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
|
||||
() => _client.PullAsync(target, Token, CancellationToken.None));
|
||||
|
||||
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "bad:file.txt", StringComparison.Ordinal));
|
||||
Assert.True(IsCheckoutPending(target));
|
||||
@@ -353,7 +353,7 @@ public sealed class PathRecoveryTests : IDisposable
|
||||
|
||||
ReplaceForgedEntry(source, remove: "bad:file.txt", add: ("fixed.txt", "fixed content"));
|
||||
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
|
||||
Assert.Equal("good content", File.ReadAllText(Path.Combine(target, "good.txt")));
|
||||
Assert.Equal("fixed content", File.ReadAllText(Path.Combine(target, "fixed.txt")));
|
||||
@@ -376,7 +376,7 @@ public sealed class PathRecoveryTests : IDisposable
|
||||
// content at the mapped name.
|
||||
string newSha = AppendForgedCommit(source, ("bad:file.txt", "v2"));
|
||||
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
|
||||
Assert.Equal("v2", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
|
||||
Assert.Equal("g1", File.ReadAllText(Path.Combine(target, "good.txt")));
|
||||
@@ -396,7 +396,7 @@ public sealed class PathRecoveryTests : IDisposable
|
||||
AppendForgedCommit(source, ("worse:new.txt", "not covered"));
|
||||
|
||||
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
|
||||
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
|
||||
() => _client.PullAsync(target, Token, CancellationToken.None));
|
||||
|
||||
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "worse:new.txt", StringComparison.Ordinal));
|
||||
// The previously materialized working tree is untouched.
|
||||
@@ -415,7 +415,7 @@ public sealed class PathRecoveryTests : IDisposable
|
||||
Assert.False(IsCheckoutPending(target));
|
||||
Assert.False(File.Exists(RecoveryFilePath(target)));
|
||||
|
||||
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
|
||||
await _client.PullAsync(target, Token, CancellationToken.None);
|
||||
Assert.False(IsCheckoutPending(target));
|
||||
Assert.False(File.Exists(RecoveryFilePath(target)));
|
||||
Assert.Equal("hello", File.ReadAllText(Path.Combine(target, "readme.txt")));
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Pins the two decisions <see cref="RemoteUrl"/> makes for a sync (#139): whether a
|
||||
/// folder's origin is the listed repository, and whether a host may be given the token.
|
||||
/// </summary>
|
||||
public sealed class RemoteUrlTests
|
||||
{
|
||||
private const string Listed = "https://github.com/acme/repo.git";
|
||||
|
||||
// ---------------------------------------------------------------- same repository
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://github.com/acme/repo.git")]
|
||||
[InlineData("https://github.com/acme/repo")]
|
||||
[InlineData("https://github.com/acme/repo/")]
|
||||
[InlineData("https://github.com/acme/repo.git/")]
|
||||
[InlineData("https://GitHub.com/Acme/Repo.GIT")]
|
||||
[InlineData(" https://github.com/acme/repo.git ")]
|
||||
[InlineData("https://someone:secret@github.com/acme/repo.git")]
|
||||
[InlineData("http://github.com/acme/repo.git")]
|
||||
[InlineData("ssh://git@github.com/acme/repo.git")]
|
||||
[InlineData("ssh://git@github.com:22/acme/repo.git")]
|
||||
[InlineData("git@github.com:acme/repo.git")]
|
||||
[InlineData("github.com:acme/repo")]
|
||||
public void IsSameRepository_SameHostAndPath_HoweverTheOriginIsSpelled(string origin)
|
||||
{
|
||||
Assert.True(RemoteUrl.IsSameRepository(Listed, origin));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://github.com/acme/other.git")]
|
||||
[InlineData("https://github.com/someone-else/repo.git")]
|
||||
[InlineData("https://github.com/acme/repo.git/extra")]
|
||||
[InlineData("https://github.example.com/acme/repo.git")]
|
||||
[InlineData("https://gitlab.com/acme/repo.git")]
|
||||
[InlineData("https://github.com:8443/acme/repo.git")]
|
||||
[InlineData("https://\u00fc-/acme/repo.git")]
|
||||
[InlineData("git@bitbucket.org:acme/repo.git")]
|
||||
[InlineData("ssh://git@old-host.example/acme/repo.git")]
|
||||
[InlineData(@"C:\src\acme\repo")]
|
||||
[InlineData("/srv/git/acme/repo.git")]
|
||||
[InlineData("file:///srv/git/acme/repo.git")]
|
||||
[InlineData("https://")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void IsSameRepository_AnotherRepository_OrNoRemoteAtAll_IsNotTheListedOne(string origin)
|
||||
{
|
||||
Assert.False(RemoteUrl.IsSameRepository(Listed, origin));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void IsSameRepository_LocalPaths_CompareAsFullPaths()
|
||||
{
|
||||
string folder = Path.Combine(Path.GetTempPath(), "gclo-tests", "remote-url", "source");
|
||||
string other = Path.Combine(Path.GetTempPath(), "gclo-tests", "remote-url", "another");
|
||||
|
||||
Assert.True(RemoteUrl.IsSameRepository(folder, folder));
|
||||
Assert.True(RemoteUrl.IsSameRepository(folder, folder + Path.DirectorySeparatorChar));
|
||||
Assert.True(RemoteUrl.IsSameRepository(folder, new Uri(folder).AbsoluteUri));
|
||||
Assert.True(RemoteUrl.IsSameRepository(folder, Path.Combine(folder, "..", "source")));
|
||||
Assert.False(RemoteUrl.IsSameRepository(folder, other));
|
||||
// A listed local path is never satisfied by a network origin.
|
||||
Assert.False(RemoteUrl.IsSameRepository(folder, Listed));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void IsSameRepository_BlankExpectedUrl_IsACallerError(string expected)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => RemoteUrl.IsSameRepository(expected, Listed));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- credentials
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://github.com/acme/repo.git")]
|
||||
[InlineData("https://github.com/acme/repo.git/info/refs?service=git-upload-pack")]
|
||||
[InlineData("https://GITHUB.com/another/path")]
|
||||
[InlineData("https://github.com:443/acme/repo.git")]
|
||||
public void MayReceiveCredentials_TheListedHostOverHttps_Yes(string requested)
|
||||
{
|
||||
Assert.True(RemoteUrl.MayReceiveCredentials(Listed, requested));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://evil.example/acme/repo.git")]
|
||||
[InlineData("https://github.com.evil.example/acme/repo.git")]
|
||||
[InlineData("https://github.com@evil.example/acme/repo.git")]
|
||||
[InlineData("https://\u00fc-/acme/repo.git")]
|
||||
[InlineData("https://github.com:8443/acme/repo.git")]
|
||||
[InlineData("http://github.com/acme/repo.git")]
|
||||
[InlineData("ssh://git@github.com/acme/repo.git")]
|
||||
[InlineData("git@github.com:acme/repo.git")]
|
||||
[InlineData("github.com/acme/repo.git")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void MayReceiveCredentials_AnyOtherHostPortOrScheme_No(string? requested)
|
||||
{
|
||||
Assert.False(RemoteUrl.MayReceiveCredentials(Listed, requested));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://github.com/acme/repo.git")]
|
||||
[InlineData(@"C:\src\acme\repo")]
|
||||
[InlineData("ssh://git@github.com/acme/repo.git")]
|
||||
public void MayReceiveCredentials_ListedUrlIsNotHttps_NobodyGetsTheToken(string listed)
|
||||
{
|
||||
Assert.False(RemoteUrl.MayReceiveCredentials(listed, listed));
|
||||
Assert.False(RemoteUrl.MayReceiveCredentials(listed, "https://github.com/acme/repo.git"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void MayReceiveCredentials_BlankExpectedUrl_IsACallerError()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => RemoteUrl.MayReceiveCredentials(" ", Listed));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- authority
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://github.com/acme/repo.git", "github.com")]
|
||||
[InlineData("https://GitHub.com:443/acme/repo.git", "github.com")]
|
||||
[InlineData("https://git.example.com:3000/acme/repo.git", "git.example.com:3000")]
|
||||
[InlineData("https://[::1]:3000/acme/repo.git", "[::1]:3000")]
|
||||
[InlineData("https://\u00fc-/acme/repo.git", null)]
|
||||
[InlineData("http://github.com/acme/repo.git", null)]
|
||||
[InlineData("git@github.com:acme/repo.git", null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData(null, null)]
|
||||
public void HttpsAuthority_IsTheHostATokenIsScopedTo_OrNullWithoutHttps(string? url, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, RemoteUrl.HttpsAuthority(url));
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- redaction
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://someone:hunter2@github.com/acme/repo.git", "https://github.com/acme/repo.git")]
|
||||
[InlineData("https://token@github.com/acme/repo.git", "https://github.com/acme/repo.git")]
|
||||
[InlineData(" https://someone:hunter2@github.com/acme/repo.git ", "https://github.com/acme/repo.git")]
|
||||
[InlineData("https://someone:hun@ter2@github.com/acme/repo.git", "https://github.com/acme/repo.git")]
|
||||
[InlineData("https://someone:hun/ter2@github.com/acme/repo.git", "https://github.com/acme/repo.git")]
|
||||
[InlineData("https://github.com/acme/repo.git?access_token=hunter2", "https://github.com/acme/repo.git")]
|
||||
[InlineData("https://github.com/acme/repo.git#hunter2", "https://github.com/acme/repo.git")]
|
||||
[InlineData("https://someone:hunter2@git.example.com:3000/acme/repo.git", "https://git.example.com:3000/acme/repo.git")]
|
||||
[InlineData("https://someone:hunter2@[::1]:3000/acme/repo.git", "https://[::1]:3000/acme/repo.git")]
|
||||
[InlineData("ssh://git@github.com", "ssh://github.com/")]
|
||||
[InlineData("https://github.com/acme/repo.git", "https://github.com/acme/repo.git")]
|
||||
[InlineData("https://github.com/acme/user@example.git", "https://github.com/acme/user@example.git")]
|
||||
[InlineData("https://github.com", "https://github.com/")]
|
||||
[InlineData("https://", "https://")]
|
||||
[InlineData(@"C:\src\acme\repo", @"C:\src\acme\repo")]
|
||||
[InlineData("git@github.com:acme/repo.git", "github.com:acme/repo.git")]
|
||||
[InlineData("someone:hunter2@github.com:acme/repo.git", "github.com:acme/repo.git")]
|
||||
[InlineData("", "")]
|
||||
[InlineData(null, "")]
|
||||
public void Redact_NeverShowsUserInformationQueryOrFragment(string? url, string expected)
|
||||
{
|
||||
string redacted = RemoteUrl.Redact(url);
|
||||
|
||||
Assert.Equal(expected, redacted);
|
||||
Assert.DoesNotContain("hunter2", redacted);
|
||||
Assert.DoesNotContain("ter2", redacted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://github.com/acme/repo.git", true)]
|
||||
[InlineData("HTTPS://github.com", true)]
|
||||
[InlineData("http://github.com/acme/repo.git", false)]
|
||||
[InlineData("ssh://git@github.com/acme/repo.git", false)]
|
||||
[InlineData("git@github.com:acme/repo.git", false)]
|
||||
[InlineData(@"C:\src\acme\repo", false)]
|
||||
[InlineData("https://\u00fc-/acme/repo.git", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsHttps_OnlyAnAbsoluteHttpsUrlWithAReadableHost(string? url, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, RemoteUrl.IsHttps(url));
|
||||
}
|
||||
}
|
||||
@@ -324,7 +324,7 @@ public sealed class WindowsPathValidationTests : IDisposable
|
||||
AppendForgedCommit(source, ("bad:incoming.txt", "x"));
|
||||
|
||||
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
|
||||
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
|
||||
() => _client.PullAsync(target, Token, CancellationToken.None));
|
||||
|
||||
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "bad:incoming.txt", StringComparison.Ordinal));
|
||||
// The valid original working tree must be untouched.
|
||||
|
||||
@@ -889,6 +889,27 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvePaths_CloneInWsl_UsesTheOrganizationTheTableWasLoadedFor_NotAnEditedBox()
|
||||
{
|
||||
MakeAlphaFailWithInvalidPaths();
|
||||
_wsl.Availability = new WslAvailability(true, "git version 2.53.0");
|
||||
WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"));
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
RepoItemViewModel row = vm.Repos[0];
|
||||
string loaded = vm.Organization;
|
||||
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(new PathRecoveryDecision.CloneInWsl());
|
||||
|
||||
// Typed after the run: the row still belongs to the organization it was listed in.
|
||||
vm.Organization = "../somewhere-else";
|
||||
await vm.ResolvePathsCommand.ExecuteAsync(row);
|
||||
|
||||
WslCloneCall call = Assert.Single(_wsl.CloneCalls);
|
||||
Assert.Equal(loaded, call.Organization);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvePaths_CloneInWsl_ShowsAnIndeterminateBar_AndLocksTheWorkspace_WhileInFlight()
|
||||
{
|
||||
|
||||
@@ -133,7 +133,7 @@ public sealed class WslClonerTests
|
||||
|
||||
ProcessCall call = Assert.Single(_runner.Calls);
|
||||
Assert.Equal(WslExe, call.FileName);
|
||||
Assert.Equal(["-e", "sh", "-s", "acme", "repo", "https://github.com/acme/repo.git"], call.Arguments);
|
||||
Assert.Equal(["-e", "sh", "-s", "acme", "repo", "https://github.com/acme/repo.git", "github.com"], call.Arguments);
|
||||
Assert.Equal(WslCloner.CloneScript, call.StandardInput);
|
||||
Assert.DoesNotContain(Token, string.Join(" ", call.Arguments));
|
||||
Assert.DoesNotContain(Token, call.StandardInput);
|
||||
@@ -156,7 +156,7 @@ public sealed class WslClonerTests
|
||||
|
||||
Assert.Contains("password=$" + WslCloner.TokenVariable, script);
|
||||
Assert.Contains("credential.helper=", script);
|
||||
Assert.Contains("git clone", script.Replace("-c credential.helper= -c \"credential.helper=$helper\" ", ""));
|
||||
Assert.Contains("git clone", script.Replace("-c credential.helper= -c \"credential.https://$host.helper=$helper\" ", ""));
|
||||
Assert.Contains("pull --ff-only", script);
|
||||
Assert.Contains("$HOME/gclo/$org/$repo", script);
|
||||
Assert.Contains("$WSL_DISTRO_NAME", script);
|
||||
@@ -268,6 +268,84 @@ public sealed class WslClonerTests
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void CloneScript_ScopesTheHelperToTheUrlsHost_AndRefusesAFolderClonedFromElsewhere()
|
||||
{
|
||||
string script = WslCloner.CloneScript;
|
||||
|
||||
// Every git invocation first clears inherited helpers, then installs the token
|
||||
// helper for the listed host only: nothing unscoped may remain (#139).
|
||||
Assert.Equal(2, CountOf(script, "-c credential.helper= -c \"credential.https://$host.helper=$helper\""));
|
||||
Assert.Equal(0, CountOf(script, "\"credential.helper=$helper\""));
|
||||
Assert.Contains("host=\"$4\"", script);
|
||||
// "--" ends option parsing, so a URL can never be read as an option.
|
||||
Assert.Contains("clone --quiet -- \"$url\" \"$dest\"", script);
|
||||
// An existing destination is only fast-forwarded when it is a clone of this URL.
|
||||
// The stored URL is compared, not "remote get-url": that one applies the user's
|
||||
// url.*.insteadOf rewrites and would refuse a destination gclo itself cloned.
|
||||
Assert.Contains("config --get remote.origin.url)\" != \"$url\"", script);
|
||||
Assert.DoesNotContain("remote get-url", script);
|
||||
Assert.True(
|
||||
script.IndexOf("config --get remote.origin.url", StringComparison.Ordinal) < script.IndexOf("pull --ff-only", StringComparison.Ordinal),
|
||||
"the origin check must come before the pull");
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Clone_PassesTheHostWithItsPort_ForAServerOnANonDefaultPort()
|
||||
{
|
||||
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "Ubuntu\n/home/me/gclo/acme/repo\n", ""));
|
||||
|
||||
await Create().CloneAsync("https://git.example.com:3000/acme/repo.git", "acme", "repo", Token, CancellationToken.None);
|
||||
|
||||
ProcessCall call = Assert.Single(_runner.Calls);
|
||||
Assert.Equal(["-e", "sh", "-s", "acme", "repo", "https://git.example.com:3000/acme/repo.git", "git.example.com:3000"], call.Arguments);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("../..", "repo")]
|
||||
[InlineData("..", "repo")]
|
||||
[InlineData("acme/other", "repo")]
|
||||
[InlineData("acme", "../../.ssh")]
|
||||
[InlineData("acme", "re/po")]
|
||||
[InlineData("acme", @"re\po")]
|
||||
public async Task Clone_NameThatWouldLeaveTheGcloFolder_IsRefused_WithoutStartingWsl(string organization, string repository)
|
||||
{
|
||||
WslCloneException ex = await Assert.ThrowsAsync<WslCloneException>(
|
||||
() => Create().CloneAsync("https://github.com/acme/repo.git", organization, repository, Token, CancellationToken.None));
|
||||
|
||||
Assert.StartsWith("Clone in WSL refused: '", ex.Message);
|
||||
Assert.Contains("cannot be used as a folder name", ex.Message);
|
||||
Assert.Empty(_runner.Calls);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://github.com/acme/repo.git")]
|
||||
[InlineData("ssh://git@github.com/acme/repo.git")]
|
||||
[InlineData("git@github.com:acme/repo.git")]
|
||||
[InlineData("--upload-pack=touch /tmp/x")]
|
||||
[InlineData(@"C:\src\acme\repo")]
|
||||
public async Task Clone_UrlThatIsNotHttps_IsRefused_WithoutStartingWsl(string url)
|
||||
{
|
||||
WslCloneException ex = await Assert.ThrowsAsync<WslCloneException>(
|
||||
() => Create().CloneAsync(url, "acme", "repo", Token, CancellationToken.None));
|
||||
|
||||
Assert.Equal("Clone in WSL refused: the clone URL is not an HTTPS URL.", ex.Message);
|
||||
Assert.Empty(_runner.Calls);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static int CountOf(string text, string fragment)
|
||||
=> (text.Length - text.Replace(fragment, "", StringComparison.Ordinal).Length) / fragment.Length;
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Defaults_PointAtSystem32Wsl()
|
||||
{
|
||||
|
||||
@@ -38,6 +38,18 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(token);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// The name goes into request paths and, with the org-subfolder option and //
|
||||
// the WSL recovery, into local paths. Anything that is not one plain name //
|
||||
// is refused before a request is made (#139). //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
if(!RepositoryPathResolver.TryValidateName(organization, out string? invalidName))
|
||||
{
|
||||
throw new GitHubAccessException(
|
||||
GitHubAccessKind.NotFound,
|
||||
$"'{organization}' is not an organization or account name ({invalidName}). Check the spelling.");
|
||||
}
|
||||
|
||||
IGitHubGateway gateway = _gatewayFactory(token);
|
||||
|
||||
// Pages manually (instead of one GetAll* call) so cancellation is honored
|
||||
|
||||
@@ -24,8 +24,19 @@ public interface IGitClient
|
||||
|
||||
|
||||
|
||||
/// <summary>Fetches from origin and fast-forwards the current branch to its upstream.</summary>
|
||||
Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Fetches <paramref name="url"/> into the repository at <paramref name="path"/> and
|
||||
/// fast-forwards the current branch to its upstream. The folder's <c>origin</c> must
|
||||
/// name the same repository as <paramref name="url"/> (scheme, credentials and a
|
||||
/// trailing <c>.git</c> aside); a folder that is a clone of something else fails
|
||||
/// without being fetched, so a sync never reports a different repository as updated.
|
||||
/// The token is offered only over HTTPS to the host of <paramref name="url"/>.
|
||||
/// </summary>
|
||||
/// <param name="url">The clone URL the repository was listed with.</param>
|
||||
/// <param name="path">Local directory of the existing clone.</param>
|
||||
/// <param name="token">Access token used for git transport.</param>
|
||||
/// <param name="cancellationToken">Cancels the pull.</param>
|
||||
Task FetchAndPullAsync(string url, string path, string token, CancellationToken cancellationToken);
|
||||
|
||||
|
||||
|
||||
|
||||
+200
-14
@@ -52,6 +52,26 @@ public sealed class LibGit2GitClient : IGitClient
|
||||
|
||||
private static readonly JsonSerializerOptions s_recoveryJsonOptions = new() { WriteIndented = true };
|
||||
|
||||
/// <summary>
|
||||
/// Name of the remote that exists only for the duration of one fetch, when a
|
||||
/// folder's origin names the listed repository but spells its URL differently
|
||||
/// (see <see cref="FetchListedUrl"/>).
|
||||
/// </summary>
|
||||
private const string SyncRemoteName = "gclo-sync";
|
||||
|
||||
/// <summary>
|
||||
/// The sync remote's own refspec. It maps to a namespace nothing is ever fetched
|
||||
/// into, so removing the remote (which deletes the tracking refs its refspecs
|
||||
/// cover) cannot touch origin's.
|
||||
/// </summary>
|
||||
private const string SyncRemoteRefSpec = "+refs/heads/*:refs/remotes/" + SyncRemoteName + "/*";
|
||||
|
||||
/// <summary>
|
||||
/// Marks the sync remote as gclo's, so cleanup never removes a remote of the same
|
||||
/// name that the user created. It goes away with the remote.
|
||||
/// </summary>
|
||||
private const string SyncRemoteMarkerKey = "remote." + SyncRemoteName + ".gclo-temporary";
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: invoked right after a fetch completed, before the cancellation
|
||||
/// check that precedes any working-tree work. Local-transport fixtures fire no
|
||||
@@ -79,9 +99,9 @@ public sealed class LibGit2GitClient : IGitClient
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
|
||||
public Task FetchAndPullAsync(string url, string path, string token, CancellationToken cancellationToken)
|
||||
=> Task.Factory.StartNew(
|
||||
() => FetchAndPull(path, token, cancellationToken),
|
||||
() => FetchAndPull(url, path, token, cancellationToken),
|
||||
cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
||||
|
||||
|
||||
@@ -106,7 +126,7 @@ public sealed class LibGit2GitClient : IGitClient
|
||||
// not a libgit2 one. On other platforms phase two is a plain checkout.
|
||||
Checkout = false,
|
||||
};
|
||||
options.FetchOptions.CredentialsProvider = MakeCredentialsProvider(token);
|
||||
options.FetchOptions.CredentialsProvider = MakeCredentialsProvider(token, url);
|
||||
int lastPercent = -1;
|
||||
options.FetchOptions.OnTransferProgress = tp =>
|
||||
{
|
||||
@@ -202,7 +222,7 @@ public sealed class LibGit2GitClient : IGitClient
|
||||
|
||||
|
||||
|
||||
private void FetchAndPull(string path, string token, CancellationToken ct)
|
||||
private void FetchAndPull(string url, string path, string token, CancellationToken ct)
|
||||
{
|
||||
using var repo = new Repository(path);
|
||||
|
||||
@@ -212,12 +232,11 @@ public sealed class LibGit2GitClient : IGitClient
|
||||
repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local);
|
||||
}
|
||||
|
||||
Remote remote = repo.Network.Remotes["origin"]
|
||||
?? throw new InvalidOperationException("Repository has no 'origin' remote.");
|
||||
Remote remote = OriginOfListedRepository(repo, url);
|
||||
|
||||
var fetchOptions = new FetchOptions
|
||||
{
|
||||
CredentialsProvider = MakeCredentialsProvider(token),
|
||||
CredentialsProvider = MakeCredentialsProvider(token, url),
|
||||
OnTransferProgress = _ => !ct.IsCancellationRequested,
|
||||
// Drop tracking refs for branches origin deleted. Without this a renamed
|
||||
// default branch leaves refs/remotes/origin/<old> frozen at its last SHA,
|
||||
@@ -225,10 +244,9 @@ public sealed class LibGit2GitClient : IGitClient
|
||||
Prune = true,
|
||||
};
|
||||
|
||||
var refSpecs = remote.FetchRefSpecs.Select(s => s.Specification).ToList();
|
||||
try
|
||||
{
|
||||
Commands.Fetch(repo, remote.Name, refSpecs, fetchOptions, logMessage: null);
|
||||
FetchListedUrl(repo, remote, url, fetchOptions);
|
||||
}
|
||||
catch(UserCancelledException) when(ct.IsCancellationRequested)
|
||||
{
|
||||
@@ -296,7 +314,9 @@ public sealed class LibGit2GitClient : IGitClient
|
||||
// Upstream is configured but gone from origin (the fetch pruned it): the
|
||||
// default branch was renamed (master -> main) or deleted. Follow origin's
|
||||
// current default instead of reporting "up to date" forever (#31).
|
||||
tracked = RetargetToRemoteDefaultBranch(repo, remote, fetchOptions.CredentialsProvider, ct);
|
||||
// A provider of its own: the fetch already spent one of the two requests
|
||||
// the replay guard allows.
|
||||
tracked = RetargetToRemoteDefaultBranch(repo, remote, url, MakeCredentialsProvider(token, url), ct);
|
||||
}
|
||||
|
||||
if(string.Equals(repo.Head.Tip?.Sha, tracked.Tip.Sha, StringComparison.Ordinal))
|
||||
@@ -346,6 +366,160 @@ public sealed class LibGit2GitClient : IGitClient
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the listed <paramref name="url"/> into <paramref name="origin"/>'s own
|
||||
/// refspecs, so the tracking refs land exactly where a fetch of origin puts them
|
||||
/// while the remote that is contacted is the one the listing returned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When origin's URL is the listed URL, which is every clone gclo made itself, this
|
||||
/// is a plain fetch of origin. Otherwise the origin names the same repository in
|
||||
/// another spelling: switched to SSH for pushing, plain HTTP, credentials typed
|
||||
/// into it. Fetching that origin would let the folder's configuration choose the
|
||||
/// transport. LibGit2Sharp cannot fetch a bare URL, so the listed URL is fetched
|
||||
/// through a remote that exists only for this call; origin itself is never
|
||||
/// rewritten. The user's own (global) git configuration still applies to that
|
||||
/// remote, as it does to everything git does for them (#139).
|
||||
/// </remarks>
|
||||
private static void FetchListedUrl(Repository repo, Remote origin, string url, FetchOptions options)
|
||||
{
|
||||
TryRemoveSyncRemote(repo); // left behind by a run that was killed mid-fetch
|
||||
|
||||
var refSpecs = origin.FetchRefSpecs.Select(s => s.Specification).ToList();
|
||||
if(string.Equals(origin.Url, url, StringComparison.Ordinal))
|
||||
{
|
||||
RefuseSshCommandOfTheFolder(repo, origin.Url);
|
||||
Commands.Fetch(repo, origin.Name, refSpecs, options, logMessage: null);
|
||||
return;
|
||||
}
|
||||
|
||||
if(repo.Network.Remotes[SyncRemoteName] is not null)
|
||||
{
|
||||
// Not marked as gclo's, or TryRemoveSyncRemote would have removed it.
|
||||
throw new InvalidOperationException(
|
||||
$"This repository has a remote of its own named '{SyncRemoteName}', a name gclo needs for a moment "
|
||||
+ "to fetch the listed URL. Rename that remote, then sync again.");
|
||||
}
|
||||
|
||||
Remote sync = repo.Network.Remotes.Add(SyncRemoteName, url, SyncRemoteRefSpec);
|
||||
repo.Config.Set(SyncRemoteMarkerKey, true, ConfigurationLevel.Local);
|
||||
try
|
||||
{
|
||||
RefuseSshCommandOfTheFolder(repo, sync.Url);
|
||||
// The fetch behaves like a fetch of origin in every respect but the URL.
|
||||
options.TagFetchMode = origin.TagFetchMode;
|
||||
Commands.Fetch(repo, SyncRemoteName, refSpecs, options, logMessage: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryRemoveSyncRemote(repo);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Removes the remote <see cref="FetchListedUrl"/> adds, when it is there and carries
|
||||
/// gclo's marker; a remote of that name the user made themselves is left alone.
|
||||
/// Best effort: a failure here (another program holding .git/config open) must not
|
||||
/// replace the outcome of the fetch, and the next sync removes what is left.
|
||||
/// </summary>
|
||||
private static void TryRemoveSyncRemote(Repository repo)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(repo.Network.Remotes[SyncRemoteName] is not null && repo.Config.Get<bool>(SyncRemoteMarkerKey)?.Value == true)
|
||||
{
|
||||
repo.Network.Remotes.Remove(SyncRemoteName);
|
||||
}
|
||||
}
|
||||
catch(LibGit2SharpException)
|
||||
{
|
||||
// See the summary: never mask the fetch's own result.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A folder's own git configuration must not decide where a sync's fetch goes. Two
|
||||
/// settings could: <c>url.<base>.insteadOf</c>, which rewrites the URL of any
|
||||
/// remote, and (see <see cref="RefuseSshCommandOfTheFolder"/>) the program used for
|
||||
/// SSH. Both are refused when they come from the folder's own configuration; the
|
||||
/// user's global configuration is theirs and is honored (#139).
|
||||
/// </summary>
|
||||
private static void RefuseUrlRewritesOfTheFolder(Repository repo)
|
||||
{
|
||||
foreach(ConfigurationEntry<string> entry in repo.Config)
|
||||
{
|
||||
if(entry.Level >= ConfigurationLevel.Local
|
||||
&& entry.Key.StartsWith("url.", StringComparison.OrdinalIgnoreCase)
|
||||
&& entry.Key.EndsWith(".insteadof", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"This folder's own git configuration rewrites remote URLs (a url.<base>.insteadOf entry in its .git/config), "
|
||||
+ "so a fetch could go somewhere other than the listed repository. Remove that entry, then sync again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// When the URL a fetch will really use is not HTTPS (the user's global git
|
||||
/// configuration rewrites it to SSH), the transport is an external SSH program.
|
||||
/// Which program must not be up to the folder: a <c>core.sshCommand</c> in its own
|
||||
/// configuration is refused (#139).
|
||||
/// </summary>
|
||||
private static void RefuseSshCommandOfTheFolder(Repository repo, string effectiveUrl)
|
||||
{
|
||||
if(RemoteUrl.IsHttps(effectiveUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach(ConfigurationEntry<string> entry in repo.Config)
|
||||
{
|
||||
if(entry.Level >= ConfigurationLevel.Local && string.Equals(entry.Key, "core.sshcommand", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"This folder's own git configuration sets core.sshCommand, and this fetch would go over SSH. "
|
||||
+ "gclo does not run a program chosen by the folder it is syncing. Remove that entry from its .git/config, then sync again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The folder's <c>origin</c>, once it is known to name the repository listed at
|
||||
/// <paramref name="url"/>. A folder with the right name is not proof of the right
|
||||
/// repository: it may be a fork, a same-named repository of another owner or host,
|
||||
/// or something another tool put there. Pulling it would report the listed
|
||||
/// repository as updated when it was not, so it fails here, before any network
|
||||
/// request (#139).
|
||||
/// </summary>
|
||||
private static Remote OriginOfListedRepository(Repository repo, string url)
|
||||
{
|
||||
RefuseUrlRewritesOfTheFolder(repo);
|
||||
|
||||
Remote remote = repo.Network.Remotes["origin"]
|
||||
?? throw new InvalidOperationException("Repository has no 'origin' remote.");
|
||||
|
||||
if(!RemoteUrl.IsSameRepository(url, remote.Url))
|
||||
{
|
||||
string folder = Path.TrimEndingDirectorySeparator(repo.Info.WorkingDirectory ?? repo.Info.Path);
|
||||
throw new InvalidOperationException(
|
||||
$"This folder is a clone of a different repository: its origin is '{RemoteUrl.Redact(remote.Url)}', not '{url}'. "
|
||||
+ "If the repository was renamed or moved to another owner, point the folder at it with "
|
||||
+ $"'git -C \"{folder}\" remote set-url origin {url}'. Otherwise move or delete the folder. Then sync again.");
|
||||
}
|
||||
|
||||
return remote;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The current branch's upstream no longer exists on origin. Asks origin for its
|
||||
/// current default branch (the remote HEAD symref), checks it out as the new
|
||||
@@ -355,12 +529,12 @@ public sealed class LibGit2GitClient : IGitClient
|
||||
/// tracking branch so the normal pull continues from it.
|
||||
/// </summary>
|
||||
private static Branch RetargetToRemoteDefaultBranch(
|
||||
Repository repo, Remote remote, CredentialsHandler credentials, CancellationToken ct)
|
||||
Repository repo, Remote remote, string url, CredentialsHandler credentials, CancellationToken ct)
|
||||
{
|
||||
const string Prefix = "refs/heads/";
|
||||
string oldName = repo.Head.FriendlyName;
|
||||
|
||||
string? defaultRef = repo.Network.ListReferences(remote, credentials)
|
||||
string? defaultRef = repo.Network.ListReferences(url, credentials)
|
||||
.FirstOrDefault(r => string.Equals(r.CanonicalName, "HEAD", StringComparison.Ordinal))?.TargetIdentifier;
|
||||
if(defaultRef is null || !defaultRef.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
@@ -803,12 +977,24 @@ public sealed class LibGit2GitClient : IGitClient
|
||||
/// up with "too many redirects or authentication replays", once per repository.
|
||||
/// A correct token is never asked for a third time, so the third request means the
|
||||
/// token is bad and gets the same actionable message the API listers produce (#31).
|
||||
/// The token is for <paramref name="expectedUrl"/>'s host only: a remote on any
|
||||
/// other host, or one reached over anything but HTTPS, is refused, so the token is
|
||||
/// never offered to a server the repository was not listed on (#139). libgit2 asks
|
||||
/// with the remote's own URL; a redirect the listed host itself issues is that
|
||||
/// host's doing, and it already holds the token.
|
||||
/// </summary>
|
||||
private static CredentialsHandler MakeCredentialsProvider(string token)
|
||||
internal static CredentialsHandler MakeCredentialsProvider(string token, string expectedUrl)
|
||||
{
|
||||
int requests = 0;
|
||||
return (_, _, _) =>
|
||||
return (requestedUrl, _, _) =>
|
||||
{
|
||||
if(!RemoteUrl.MayReceiveCredentials(expectedUrl, requestedUrl))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"'{RemoteUrl.Redact(requestedUrl)}' asked for credentials, but the token is only sent over HTTPS "
|
||||
+ "to the host the repository was listed on. Nothing was sent.");
|
||||
}
|
||||
|
||||
if(Interlocked.Increment(ref requests) > 2)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
||||
@@ -141,7 +141,7 @@ public sealed class OrgSyncEngine
|
||||
if(_git.IsValidRepository(path))
|
||||
{
|
||||
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Pulling));
|
||||
await _git.FetchAndPullAsync(path, request.Token, token).WaitAsync(token).ConfigureAwait(false);
|
||||
await _git.FetchAndPullAsync(repo.CloneUrl, path, request.Token, token).WaitAsync(token).ConfigureAwait(false);
|
||||
Interlocked.Increment(ref updated);
|
||||
}
|
||||
else
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Decides two things about git remote URLs, both from the clone URL a listing returned
|
||||
/// (the "expected" URL): whether an existing folder's <c>origin</c> names that same
|
||||
/// repository, and whether a remote that asks for credentials is one the token is
|
||||
/// meant for. A sync must never report a different repository as updated, and must
|
||||
/// never hand the token to a remote on another host (#139).
|
||||
/// </summary>
|
||||
internal static class RemoteUrl
|
||||
{
|
||||
private static readonly char[] s_separators = ['/', '\\'];
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="actual"/> (a folder's configured origin) names the same
|
||||
/// repository as <paramref name="expected"/> (the listed clone URL). Network URLs
|
||||
/// are compared by host and path, ignoring scheme, credentials, letter case, a
|
||||
/// trailing slash and a trailing <c>.git</c>, so an origin switched to SSH still
|
||||
/// matches its HTTPS listing; the port counts only between two HTTP(S) URLs, where
|
||||
/// it tells two servers on one host apart. Local paths (the offline test fixtures,
|
||||
/// or a caller's own mirror) are compared as full paths. A network URL never
|
||||
/// matches a local path, and a URL that cannot be read matches nothing.
|
||||
/// </summary>
|
||||
public static bool IsSameRepository(string expected, string actual)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(expected);
|
||||
if(string.IsNullOrWhiteSpace(actual))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Location expectedLocation = Parse(expected);
|
||||
Location actualLocation = Parse(actual);
|
||||
if(expectedLocation.Kind != actualLocation.Kind || expectedLocation.Kind == LocationKind.Unreadable)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if(expectedLocation.Kind == LocationKind.Local)
|
||||
{
|
||||
return string.Equals(
|
||||
expectedLocation.Path, actualLocation.Path,
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return string.Equals(expectedLocation.Host, actualLocation.Host, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(expectedLocation.Path, actualLocation.Path, StringComparison.OrdinalIgnoreCase)
|
||||
&& (expectedLocation.WebPort is null || actualLocation.WebPort is null || expectedLocation.WebPort == actualLocation.WebPort);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when a credential request from a remote whose URL is
|
||||
/// <paramref name="requested"/> may be answered with the token of a sync whose listed
|
||||
/// clone URL is <paramref name="expected"/>: both must be HTTPS and name the same
|
||||
/// host and port. A remote on another host, plain HTTP, and SSH get no token.
|
||||
/// </summary>
|
||||
public static bool MayReceiveCredentials(string expected, string? requested)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(expected);
|
||||
return TryParseHttps(expected, out Uri? expectedUri, out string? expectedHost)
|
||||
&& TryParseHttps(requested, out Uri? requestedUri, out string? requestedHost)
|
||||
&& string.Equals(expectedHost, requestedHost, StringComparison.OrdinalIgnoreCase)
|
||||
&& expectedUri.Port == requestedUri.Port;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when <paramref name="url"/> is an absolute HTTPS URL with a readable host.</summary>
|
||||
public static bool IsHttps(string? url) => TryParseHttps(url, out _, out _);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The host of an HTTPS <paramref name="url"/>, with its port when that is not 443
|
||||
/// and with the brackets of an IPv6 literal; null when the URL is not absolute HTTPS.
|
||||
/// This is the scope a token may be used in.
|
||||
/// </summary>
|
||||
public static string? HttpsAuthority(string? url)
|
||||
{
|
||||
if(!TryParseHttps(url, out Uri? uri, out string? host))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string authority = uri.HostNameType == UriHostNameType.IPv6 ? $"[{host}]" : host;
|
||||
return uri.IsDefaultPort ? authority : authority + ":" + uri.Port.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="url"/> in a form that is safe to show and log: an origin may
|
||||
/// carry credentials someone typed into it. A readable network URL is rebuilt from
|
||||
/// its scheme, host, port and path, so user information, query and fragment are
|
||||
/// gone; anything else loses everything up to its last <c>@</c>.
|
||||
/// </summary>
|
||||
public static string Redact(string? url)
|
||||
{
|
||||
if(string.IsNullOrEmpty(url))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string trimmed = url.Trim();
|
||||
int scheme = trimmed.IndexOf("://", StringComparison.Ordinal);
|
||||
if(scheme > 0 && Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri) && !uri.IsFile && TryGetHost(uri, out string? host))
|
||||
{
|
||||
string authority = uri.HostNameType == UriHostNameType.IPv6 ? $"[{host}]" : host;
|
||||
string port = uri.IsDefaultPort ? "" : ":" + uri.Port.ToString(CultureInfo.InvariantCulture);
|
||||
return $"{uri.Scheme}://{authority}{port}{uri.AbsolutePath}";
|
||||
}
|
||||
|
||||
int at = trimmed.LastIndexOf('@');
|
||||
if(at < 0)
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return scheme > 0 ? string.Concat(trimmed.AsSpan(0, scheme + 3), trimmed.AsSpan(at + 1)) : trimmed[(at + 1)..];
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static bool TryParseHttps(string? url, [NotNullWhen(true)] out Uri? uri, [NotNullWhen(true)] out string? host)
|
||||
{
|
||||
host = null;
|
||||
return Uri.TryCreate(url, UriKind.Absolute, out uri)
|
||||
&& string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
|
||||
&& TryGetHost(uri, out host);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The host in its ASCII (punycode) form. <see cref="Uri.IdnHost"/> throws for a
|
||||
/// host that parses as a URI but is not a valid internationalized name; such a
|
||||
/// host is nobody's, not an error.
|
||||
/// </summary>
|
||||
private static bool TryGetHost(Uri uri, [NotNullWhen(true)] out string? host)
|
||||
{
|
||||
try
|
||||
{
|
||||
host = uri.IdnHost;
|
||||
return host.Length > 0;
|
||||
}
|
||||
catch(UriFormatException)
|
||||
{
|
||||
host = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static Location Parse(string url)
|
||||
{
|
||||
string trimmed = url.Trim();
|
||||
int scheme = trimmed.IndexOf("://", StringComparison.Ordinal);
|
||||
if(scheme > 0)
|
||||
{
|
||||
if(!Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri))
|
||||
{
|
||||
return new Location(LocationKind.Unreadable, "", "", null);
|
||||
}
|
||||
|
||||
if(uri.IsFile)
|
||||
{
|
||||
return Local(uri.LocalPath);
|
||||
}
|
||||
|
||||
bool web = uri.Scheme is "http" or "https";
|
||||
return TryGetHost(uri, out string? host)
|
||||
? new Location(LocationKind.Network, host, NormalizePath(Uri.UnescapeDataString(uri.AbsolutePath)), web ? (uri.IsDefaultPort ? 0 : uri.Port) : null)
|
||||
: new Location(LocationKind.Unreadable, "", "", null);
|
||||
}
|
||||
|
||||
// The scp-like form git accepts for SSH: [user@]host:path. A single letter before
|
||||
// the colon is a Windows drive ("C:\src\repo"), not a host, and a separator
|
||||
// before the colon means the colon belongs to a local path.
|
||||
int colon = trimmed.IndexOf(':', StringComparison.Ordinal);
|
||||
if(colon >= 0)
|
||||
{
|
||||
string host = trimmed[(trimmed.LastIndexOf('@', colon) + 1)..colon];
|
||||
string path = trimmed[(colon + 1)..];
|
||||
if(host.Length >= 2 && host.IndexOfAny(s_separators) < 0 && path.Length > 0 && path[0] != '\\')
|
||||
{
|
||||
return new Location(LocationKind.Network, host, NormalizePath(path), null);
|
||||
}
|
||||
}
|
||||
|
||||
return Local(trimmed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static Location Local(string path)
|
||||
=> new(LocationKind.Local, "", Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)), null);
|
||||
|
||||
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
string trimmed = path.Trim('/');
|
||||
return trimmed.EndsWith(".git", StringComparison.OrdinalIgnoreCase) ? trimmed[..^4].TrimEnd('/') : trimmed;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private enum LocationKind
|
||||
{
|
||||
Network,
|
||||
Local,
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Where a git URL points: a host and path, or a local full path.</summary>
|
||||
/// <param name="Kind">Network, local, or not readable as either.</param>
|
||||
/// <param name="Host">ASCII host of a network URL; empty otherwise.</param>
|
||||
/// <param name="Path">Normalized repository path, or the local full path.</param>
|
||||
/// <param name="WebPort">Port of an HTTP(S) URL, 0 for the scheme's default (so HTTP and HTTPS on their usual ports agree); null for SSH, scp-like and local.</param>
|
||||
private readonly record struct Location(LocationKind Kind, string Host, string Path, int? WebPort);
|
||||
}
|
||||
@@ -20,21 +20,29 @@ public sealed class WslCloner : IWslCloner
|
||||
public const string TokenVariable = "GCLO_GIT_TOKEN";
|
||||
|
||||
/// <summary>
|
||||
/// The clone script. $1 organization, $2 repository, $3 clone URL. Prints the
|
||||
/// distribution name and the destination as its last two lines on success. A folder
|
||||
/// that already is a repository is fast-forwarded instead of failing, so the verb is
|
||||
/// safe to use twice.
|
||||
/// The clone script. $1 organization, $2 repository, $3 clone URL, $4 the URL's
|
||||
/// host (with a non-default port). Prints the distribution name and the destination
|
||||
/// as its last two lines on success. A folder that already is a clone of the same
|
||||
/// URL is fast-forwarded instead of failing, so the verb is safe to use twice; a
|
||||
/// folder that is a clone of anything else is refused. The credential helper is
|
||||
/// scoped to $4, so git (and anything it starts, such as an LFS filter pointed at
|
||||
/// another server by the repository's own configuration) gets the token for that
|
||||
/// host only (#139).
|
||||
/// </summary>
|
||||
internal const string CloneScript =
|
||||
"set -e\n"
|
||||
+ "org=\"$1\"; repo=\"$2\"; url=\"$3\"\n"
|
||||
+ "org=\"$1\"; repo=\"$2\"; url=\"$3\"; host=\"$4\"\n"
|
||||
+ "dest=\"$HOME/gclo/$org/$repo\"\n"
|
||||
+ "helper='!f() { echo username=x-access-token; echo \"password=$" + TokenVariable + "\"; }; f'\n"
|
||||
+ "if [ -d \"$dest/.git\" ]; then\n"
|
||||
+ " git -C \"$dest\" -c credential.helper= -c \"credential.helper=$helper\" pull --ff-only --quiet\n"
|
||||
+ " if [ \"$(git -C \"$dest\" config --get remote.origin.url)\" != \"$url\" ]; then\n"
|
||||
+ " echo \"$dest exists but is not a clone of this repository; move or delete it, then try again\" >&2\n"
|
||||
+ " exit 1\n"
|
||||
+ " fi\n"
|
||||
+ " git -C \"$dest\" -c credential.helper= -c \"credential.https://$host.helper=$helper\" pull --ff-only --quiet\n"
|
||||
+ "else\n"
|
||||
+ " mkdir -p \"$HOME/gclo/$org\"\n"
|
||||
+ " git -c credential.helper= -c \"credential.helper=$helper\" clone --quiet \"$url\" \"$dest\"\n"
|
||||
+ " git -c credential.helper= -c \"credential.https://$host.helper=$helper\" clone --quiet -- \"$url\" \"$dest\"\n"
|
||||
+ "fi\n"
|
||||
+ "printf '%s\\n%s\\n' \"$WSL_DISTRO_NAME\" \"$dest\"\n";
|
||||
|
||||
@@ -102,6 +110,23 @@ public sealed class WslCloner : IWslCloner
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName);
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// The organization and repository become path segments under ~/gclo, and //
|
||||
// the URL's host is what the token is scoped to. None of the three is taken //
|
||||
// on trust: a name with a separator or ".." would leave ~/gclo, and a URL //
|
||||
// that is not HTTPS has no host the token could safely be tied to (#139). //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
foreach(string name in new[] { organization, repositoryName })
|
||||
{
|
||||
if(!RepositoryPathResolver.TryValidateName(name, out string? reason))
|
||||
{
|
||||
throw new WslCloneException($"Clone in WSL refused: '{name}' cannot be used as a folder name ({reason}).");
|
||||
}
|
||||
}
|
||||
|
||||
string host = RemoteUrl.HttpsAuthority(url)
|
||||
?? throw new WslCloneException("Clone in WSL refused: the clone URL is not an HTTPS URL.");
|
||||
|
||||
var environment = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[TokenVariable] = token,
|
||||
@@ -115,7 +140,7 @@ public sealed class WslCloner : IWslCloner
|
||||
{
|
||||
result = await _runner.RunAsync(
|
||||
_wslExe,
|
||||
["-e", "sh", "-s", organization, repositoryName, url],
|
||||
["-e", "sh", "-s", organization, repositoryName, url, host],
|
||||
environment,
|
||||
CloneScript,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -1102,8 +1102,10 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
_log.Info($"{item.Name}: cloning in WSL instead.");
|
||||
item.Status = SyncStatus.Pulling; // indeterminate bar; git in WSL reports no percentage here
|
||||
// The organization the table was loaded for, not whatever the box says now:
|
||||
// the row belongs to that organization, and its folder under ~/gclo should too.
|
||||
WslCloneResult result = await _wsl.CloneAsync(
|
||||
item.Descriptor.CloneUrl, Organization.Trim(), item.Name, Token.Trim(), _lifetimeCts.Token);
|
||||
item.Descriptor.CloneUrl, LoadedOrganization ?? Organization.Trim(), item.Name, Token.Trim(), _lifetimeCts.Token);
|
||||
|
||||
item.Status = SyncStatus.Done;
|
||||
item.Error = null;
|
||||
|
||||
@@ -90,8 +90,8 @@ internal static class UiTestFixture
|
||||
|
||||
|
||||
|
||||
public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
|
||||
=> string.Equals(token, Token, StringComparison.Ordinal) ? Task.CompletedTask : real.FetchAndPullAsync(path, token, cancellationToken);
|
||||
public Task FetchAndPullAsync(string url, string path, string token, CancellationToken cancellationToken)
|
||||
=> string.Equals(token, Token, StringComparison.Ordinal) ? Task.CompletedTask : real.FetchAndPullAsync(url, path, token, cancellationToken);
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user