Repository files navigation

ktsu.GitIntegration

A .NET library that wraps the git binary behind a fluent, strongly-typed interface, and unifies access to hosted Git providers behind a single abstraction.

LicenseNuGet VersionNuGet VersionNuGet DownloadsGitHub commit activityGitHub contributorsGitHub Actions Workflow Status

Introduction

ktsu.GitIntegration is a two-layer library. The local layer wraps the git executable found on PATH behind a fluent, strongly-typed interface: open or discover a repository, then build and run both read-only commands (status, log, diff, branches, remotes, rev-parse) and mutating commands (init, clone, add, commit, branch creation and deletion, checkout, remote management, and remote sync via fetch, pull, and push) without shelling out or hand-parsing porcelain output yourself. The hosting layer — the original half of this library — unifies access to hosted Git providers behind a GitProvider abstraction: GitHubProvider, built on Octokit, and AzureDevOpsProvider, built on a raw HttpClient against Azure DevOps's REST API. Both enumerate repositories and list and create pull requests through the same IGitHostingProvider contract.

Every value that would otherwise be a bare string — a branch name, a commit SHA, a remote name, an author email — is instead a validated semantic type built on ktsu.Semantics, so a GitBranchName can no longer be accidentally passed where a GitCommitSha is expected.

Azure DevOps hosting deliberately does not use Microsoft.TeamFoundationServer.Client or the other official TFS/Azure DevOps client package — both pull in System.Data.SqlClient, which carries a known high-severity advisory, as a direct dependency of the published package. AzureDevOpsProvider builds and parses its requests by hand instead.

Features

  • Local Git Client: IGitClient/GitClient finds and opens repositories — GetVersionAsync, IsRepositoryAsync, OpenAsync, DiscoverAsync — and creates new ones — Init(...), Clone(...) — by delegating every invocation to ktsu.RunCommand.
  • Fluent Verb Builders: GitRepository exposes one builder per read-only verb — Status(), Log(), Diff(), Branches(), Remotes(), RevParse(...) — and one per mutating verb — Add(), Commit(...), CreateBranch(...), DeleteBranch(...), Checkout(...), AddRemote(...), RemoveRemote(...), SetRemoteUrl(...), Fetch(), Pull(), Push() — each configurable via chained method calls and run with ExecuteAsync or the non-throwing TryExecuteAsync.
  • Remote Sync: Fetch() downloads objects and refs without touching the working tree, Pull() fetches and integrates into the current branch, and Push() sends local commits — fetch and push report a machine-readable, per-reference account of what happened via GitFetchResult/GitPushResult, and a rejected push is the one place in this library where ExecuteAsync and TryExecuteAsync diverge in more than exception-versus-result.
  • Strongly-Typed Results: GitStatus, GitCommit, GitBranch, GitRemote, GitDiffEntry, GitVersion, GitInitResult, GitCompleted, GitFetchResult, GitPushResult, and GitRefUpdate records replace ad-hoc porcelain parsing with typed models — GitCompleted is the shared result for mutating verbs whose only outcome is success.
  • Reproducible Failures: every command is scoped with git -C <path> instead of a process working directory, so a failing invocation's exact argument vector can be read off a GitCommandException and rerun verbatim.
  • Locale-Safe Parsing: every invocation runs with GIT_TERMINAL_PROMPT=0 (no hanging credential prompts) and LC_ALL=C (English, machine-stable output), which is what makes the output parsers safe to write against fixed English text.
  • Dependency Injection: AddGitIntegration() registers the client, process runner, and options as singletons in one call. The hosting layer is constructed directly instead — see Working with a Hosting Provider.
  • Hosting Provider Abstraction: IGitHostingProvider defines a common contract for enumerating repositories, listing open pull requests, and creating a pull request — GitHubProvider implements it on top of Octokit, AzureDevOpsProvider on a raw HttpClient against Azure DevOps's REST API.
  • Credential Resolution: hosting providers integrate with ktsu.CredentialCache, so credentials come from the host's native keyring rather than configuration files.
  • Semantic Git Types: validated wrapper types for every identifier Git tooling passes around, so mismatched arguments fail at compile time rather than at runtime.

Installation

Package Manager Console

Install-Package ktsu.GitIntegration

.NET CLI

dotnet add package ktsu.GitIntegration

Package Reference

<PackageReferenceInclude="ktsu.GitIntegration"Version="x.y.z" />

Usage Examples

Basic Example

Register the library with dependency injection, then resolve IGitClient:

usingktsu.GitIntegration;usingMicrosoft.Extensions.DependencyInjection;ServiceCollectionservices=new();services.AddGitIntegration();usingServiceProviderprovider=services.BuildServiceProvider();IGitClientclient=provider.GetRequiredService<IGitClient>();

Discovering a Repository and Reading Its Status

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathhere=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepository?repository=awaitclient.DiscoverAsync(here);if(repositoryis not null){GitStatusstatus=awaitrepository.Status().ExecuteAsync();Console.WriteLine(status.IsClean?"Working tree is clean.":$"{status.Entries.Count} changed path(s) on {status.Branch?.WeakString}.");}

Listing Commits and Diffs

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IReadOnlyList<GitCommit>commits=awaitrepository.Log().Take(10).FirstParentOnly().ExecuteAsync();foreach(GitCommitcommitincommits){Console.WriteLine($"{commit.Sha.WeakString[..7]}{commit.Subject}");}IReadOnlyList<GitDiffEntry>changes=awaitrepository.Diff().Staged().DetectRenames().ExecuteAsync();

Initializing or Cloning a Repository

Init probes the target path before running git init, so GitInitResult.AlreadyExisted can tell a caller whether a repository was already there — git init is idempotent and silently ignores --initial-branch when re-initialising, so a caller that asked for a particular initial branch and got AlreadyExisted == true did not get the branch it asked for:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathtarget=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitInitResultinit=awaitclient.Init(target).WithInitialBranch("main".As<GitBranchName>()).ExecuteAsync();GitRepositoryrepository=init.Repository;

Clone builds git clone. Its destination pre-check is advisory only — git enforces the same rule itself, so the check exists solely to fail a doomed clone before it pays its network cost, and it is deliberately racy:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;GitRepositoryRemotePathsource="https://github.com/ktsu-dev/GitIntegration.git".As<GitRepositoryRemotePath>();AbsoluteDirectoryPathdestination=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepositorycloned=awaitclient.Clone(source,destination).WithDepth(1).ReportingProgress(newProgress<string>(line =>Console.WriteLine(line))).ExecuteAsync();

Staging and Committing Changes

Commit runs git twice: git commit itself, then git log -1 with this library's pinned format, because commit's own output is a human summary carrying only an abbreviated object id, with no machine-readable alternative:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.Add().All().ExecuteAsync();GitCommitcommit=awaitrepository.Commit("Add feature X".As<GitCommitMessage>()).WithBody("Longer explanation of the change.").WithAuthor("Ada Lovelace".As<GitAuthorName>(),"ada@example.com".As<GitAuthorEmail>()).ExecuteAsync();Console.WriteLine(commit.Sha.WeakString);

Committing with nothing staged throws GitNothingToCommitException, a GitCommandException specialization, rather than the generic base type — the one commit failure that is an ordinary program state rather than a fault:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Commit("Nothing changed".As<GitCommitMessage>()).ExecuteAsync();}catch(GitNothingToCommitException){Console.WriteLine("Nothing was staged; skipping this commit.");}

Creating Branches and Switching

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.CreateBranch("feature/new-thing".As<GitBranchName>()).StartingAt("main".As<GitRefName>()).ExecuteAsync();awaitrepository.Checkout("feature/new-thing".As<GitRefName>()).ExecuteAsync();// Later, once the branch is no longer needed:awaitrepository.DeleteBranch("feature/new-thing".As<GitBranchName>()).Force().ExecuteAsync();

Managing Remotes

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryRemotePathurl="https://github.com/example/repo.git".As<GitRepositoryRemotePath>();awaitrepository.AddRemote("upstream".As<GitRemoteName>(),url).WithFetch().ExecuteAsync();awaitrepository.SetRemoteUrl("upstream".As<GitRemoteName>(),url).ForPushOnly().ExecuteAsync();awaitrepository.RemoveRemote("upstream".As<GitRemoteName>()).ExecuteAsync();

Fetching

fetch --porcelain is only available from git 2.41 onward, so Fetch() probes the installed git's version first. Below that threshold the fetch still runs and still succeeds, but GitFetchResult.DetailAvailable is false and Updates is empty — check DetailAvailable before trusting an empty Updates as "nothing changed":

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitFetchResultfetched=awaitrepository.Fetch().FromRemote("origin".As<GitRemoteName>()).Prune().WithTags().ExecuteAsync();if(fetched.DetailAvailable){Console.WriteLine(fetched.IsUpToDate?"Already up to date.":$"{fetched.Updates.Count} reference(s) updated.");}else{Console.WriteLine("Fetch completed, but this git is older than 2.41 so no per-reference detail is available.");}

Pulling

Pull() returns GitCompleted rather than a parsed result, because everything git pull prints is human prose with no porcelain form. Use Status() and Log() afterwards to learn what changed. A merge conflict is the one outcome with its own exception, GitPullConflictException, because it leaves the repository mid-merge:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Pull().FromRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).FastForwardOnly().ExecuteAsync();}catch(GitPullConflictException){GitStatusstatus=awaitrepository.Status().ExecuteAsync();IEnumerable<GitStatusEntry>unmerged=status.Entries.Where(e =>e.IndexState==GitFileState.Unmerged);Console.WriteLine($"Pull left {unmerged.Count()} unmerged path(s); resolve them and commit.");}

FastForwardOnly() and Rebase() are mutually exclusive — combining them throws InvalidOperationException when the argument vector is built, since they mean opposite things about history.

Pushing — Why ExecuteAsync and TryExecuteAsync Disagree

push is the one verb in this library where the two entry points mean genuinely different things, not just exception-versus-result. A rejected push exits non-zero from git and prints a complete porcelain record of every reference — git got far enough to talk about them and refused some. A caller who does not know this will get it wrong by assuming TryExecuteAsync returning Success means the push landed:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;// ExecuteAsync stays strict: a rejection throws, and the exception carries the full parsed result.try{GitPushResultpushed=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).SettingUpstream().ExecuteAsync();}catch(GitPushRejectedExceptionex){// ex.Result is the same GitPushResult a successful push would have returned.foreach(GitRefUpdateupdateinex.Result!.Updates.Where(u =>u.IsRejected)){Console.WriteLine($"{update.Reference.WeakString}: {update.Summary}");}}

TryExecuteAsync does not throw for a rejection — git ran and reported exactly what happened, so that report comes back as a successful GitResult. Always check HasRejections, not just Success:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitPushResult>result=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).TryExecuteAsync();if(result.Success&&result.Value!.HasRejections){// This is still result.Success == true: TryExecuteAsync only fails when git never reached// the remote at all. A rejection is reported as data, not as GitResult failure.Console.WriteLine("Push ran but at least one reference was rejected — check result.Value.Updates.");}

ForceWithLease() wins over Force() when both are set, being the safer of the two — it refuses if the remote moved since it was last fetched.

Resolving a Revision Without Throwing

TryExecuteAsync reports a non-zero exit as a result instead of an exception — useful when "no such revision" is an expected outcome rather than a failure:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitCommitSha>result=awaitrepository.RevParse("maybe-missing-branch".As<GitRefName>()).TryExecuteAsync();if(result.Success){Console.WriteLine(result.Value!.WeakString);}else{Console.WriteLine($"git exited {result.Error!.ExitCode}: {result.Error.StandardError}");}

Reproducing a Failing Command

ExecuteAsync throws GitCommandException on a non-zero exit, carrying the exact argument vector git was invoked with:

try{awaitrepository.RevParse("no-such-ref".As<GitRefName>()).ExecuteAsync();}catch(GitCommandExceptionex){// ex.Arguments already begins with "-C <path>", so this can be pasted straight after `git`// on a command line to reproduce the failure exactly.Console.WriteLine("git "+string.Join(' ',ex.Arguments));}

Working with a Hosting Provider

Providers are constructed directly rather than resolved from DI: GitHubProvider and AzureDevOpsProvider need only a caller-supplied Owner (and, for Azure DevOps, an optional Project), so there is nothing a container would meaningfully wire up.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProvidergithub=newGitHubProvider{Owner="ktsu-dev".As<GitProviderOwner>(),};// Credentials come from ktsu.CredentialCache, keyed by PersonaGUID — nothing to configure here// unless a specific persona is needed.IReadOnlyList<GitRepository>repositories=awaitgithub.GetRepositoriesAsync();

GitHubProvider.GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones. Azure DevOps has no equivalent restriction: it returns everything the resolved credential can see.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProviderazure=newAzureDevOpsProvider{Owner="my-org".As<GitProviderOwner>(),Project="my-project".As<AzureDevOpsProjectName>(),};IReadOnlyList<GitRepository>repositories=awaitazure.GetRepositoriesAsync();

Project is only required for pull request operations — Azure DevOps has no project-less pull request endpoint, and calling GetPullRequestsAsync or CreatePullRequest without it throws InvalidOperationException immediately. GetPullRequestsAsync returns open pull requests only, on both hosts:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryNamerepositoryName="GitIntegration".As<GitRepositoryName>();IReadOnlyList<GitPullRequest>openPullRequests=awaitazure.GetPullRequestsAsync(repositoryName);foreach(GitPullRequestpullRequestinopenPullRequests){Console.WriteLine($"#{pullRequest.Number.WeakString}{pullRequest.Title.WeakString} ({pullRequest.State})");}

Creating a pull request goes through a builder, the same idiom as the local layer's mutating verbs:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitPullRequestcreated=awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).Describing("Longer explanation of the change.").ExecuteAsync();Console.WriteLine(created.WebURI?.WeakString);

A hosting failure — authentication, not found, rate limiting, or anything else the host reports — surfaces as a GitHostingException subtype carrying the provider name, HTTP status, and response body, mirroring how GitCommandException carries the argument vector for the local layer:

try{awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).ExecuteAsync();}catch(GitHostingAuthenticationExceptionex){Console.WriteLine($"{ex.ProviderName} rejected the request: {ex.StatusCode}");}catch(GitHostingRateLimitExceptionex){Console.WriteLine($"Rate limited until {ex.ResetsAt}.");}

Working with Semantic Types

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitBranchNamebranch="main".As<GitBranchName>();GitRemoteNameremote="origin".As<GitRemoteName>();GitCommitShasha="9fceb02d0ae598e95dc970b74767f19372d61af8".As<GitCommitSha>();// These are distinct types — passing a GitBranchName where a GitCommitSha// is expected is a compile error, not a runtime surprise.

Advanced Usage

Argument Vectors Are Inspectable Before They Run

Every builder's BuildArguments() is a pure computation with no I/O, so the exact command can be asserted or logged before it executes:

IReadOnlyList<string>arguments=repository.Status().BuildArguments();// ["-C", "<path>", "--no-pager", "-c", "core.quotepath=false", "-c", "color.ui=false",// "status", "--porcelain=v2", "--branch", "-z"]

Metadata-Only Repositories

A GitRepository produced by a hosting provider (rather than IGitClient.OpenAsync or DiscoverAsync) carries hosting metadata but no ProcessRunner. Calling any verb on it throws InvalidOperationException immediately, rather than failing later inside git:

GitRepositorymetadataOnly=new(){LocalPath=somePath,Name="GitIntegration".As<GitRepositoryName>()};// Throws InvalidOperationException — obtain a runnable repository from IGitClient first._=metadataOnly.Status();

API Reference

IGitClient / GitClient

The entry point to the local layer: finds and opens repositories, and reports on the git binary.

Methods

NameReturn TypeDescription
GetVersionAsync(CancellationToken)Task<GitVersion>Reports the version of the git binary being invoked.
IsRepositoryAsync(AbsoluteDirectoryPath, CancellationToken)Task<bool>Decides whether a path is inside a git working tree. Never throws for a non-repository path.
OpenAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository>Opens the repository containing a path. Throws GitRepositoryNotFoundException when there is none.
DiscoverAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository?>Opens the repository containing a path, returning null instead of throwing when there is none.
Init(AbsoluteDirectoryPath)IGitInitBuilderCreates a repository at a path. Probes first, so the result's AlreadyExisted can tell a caller whether one was already there.
Clone(GitRepositoryRemotePath, AbsoluteDirectoryPath)IGitCloneBuilderClones a repository into a local working copy.
Clone(GitRepository)IGitCloneBuilderClones the repository a hosting provider described, using its RemotePath and intended LocalPath.

GitRepository

Carries LocalPath plus optional hosting metadata, and exposes one builder factory per verb.

Properties

NameTypeDescription
LocalPathAbsoluteDirectoryPathThe working tree's local filesystem path.
NameGitRepositoryName?The repository name, when known.
WebURIGitRepositoryWebURI?The browser-facing URI, when known.
RemotePathGitRepositoryRemotePath?The remote clone path, when known.
ProcessRunnerIGitProcessRunner?The runner this repository's verbs execute through; null on a metadata-only repository.

Methods

NameReturn TypeDescription
Status()IGitStatusBuilderBuilds git status --porcelain=v2 --branch -z.
Log()IGitLogBuilderBuilds git log -z with this library's pinned format.
Diff()IGitDiffBuilderBuilds git diff --name-status -z.
Branches()IGitBranchListBuilderBuilds git for-each-ref over the branch namespaces.
Remotes()IGitRemoteListBuilderBuilds git remote -v.
RevParse(GitRefName)IGitRevParseBuilderBuilds git rev-parse --verify for a revision.
Add()IGitAddBuilderBuilds git add.
Commit(GitCommitMessage)IGitCommitBuilderBuilds git commit, then reads the new commit back with git log -1.
CreateBranch(GitBranchName)IGitBranchCreateBuilderBuilds git branch <name> [<start-point>].
DeleteBranch(GitBranchName)IGitBranchDeleteBuilderBuilds git branch --delete <name>.
Checkout(GitRefName)IGitCheckoutBuilderBuilds git checkout.
AddRemote(GitRemoteName, GitRepositoryRemotePath)IGitRemoteAddBuilderBuilds git remote add <name> <url>.
RemoveRemote(GitRemoteName)IGitRemoteRemoveBuilderBuilds git remote remove <name>.
SetRemoteUrl(GitRemoteName, GitRepositoryRemotePath)IGitRemoteSetUrlBuilderBuilds git remote set-url <name> <url>.
Fetch()IGitFetchBuilderBuilds git fetch, with --porcelain where the installed git supports it.
Pull()IGitPullBuilderBuilds git pull.
Push()IGitPushBuilderBuilds git push --porcelain.
IsClonedAsync(CancellationToken)Task<bool>Decides whether LocalPath currently holds a git working tree.
OpenWebClient()voidOpens WebURI in the default browser, when it is an absolute http/https URI.

IGitCommandBuilder<TResult>

The shared contract every verb builder implements. A builder is single-use and not thread-safe.

Methods

NameReturn TypeDescription
BuildArguments()IReadOnlyList<string>The exact argument vector this builder will pass to git. Pure, no I/O.
ExecuteAsync(CancellationToken)Task<TResult>Runs the command, throwing GitCommandException when git exits non-zero.
TryExecuteAsync(CancellationToken)Task<GitResult<TResult>>Runs the command, reporting a non-zero exit as a result instead of throwing.

Verb Builders

InterfaceExtra MethodsResult
IGitStatusBuilderWithUntrackedFiles(GitUntrackedFilesMode), IncludeIgnored()GitStatus
IGitLogBuilderTake(int), Skip(int), ForRevision(GitRefName), ForPath(RelativeFilePath), FirstParentOnly()IReadOnlyList<GitCommit>
IGitDiffBuilderStaged(), Against(GitRefName), Between(GitRefName, GitRefName), DetectRenames(), DetectCopies(), ForPath(RelativeFilePath)IReadOnlyList<GitDiffEntry>
IGitBranchListBuilderLocalOnly(), RemoteOnly()IReadOnlyList<GitBranch>
IGitRemoteListBuilder(none)IReadOnlyList<GitRemote>
IGitRevParseBuilder(none — revision supplied via GitRepository.RevParse)GitCommitSha
IGitInitBuilderBare(), WithInitialBranch(GitBranchName)GitInitResult
IGitCloneBuilderWithBranch(GitBranchName), WithDepth(int), Bare(), ReportingProgress(IProgress<string>)GitRepository
IGitAddBuilderForPath(RelativeFilePath), All(), UpdateTrackedOnly()GitCompleted
IGitCommitBuilderWithBody(string), AllowEmpty(), StageTrackedFiles(), WithAuthor(GitAuthorName, GitAuthorEmail)GitCommit
IGitBranchCreateBuilderStartingAt(GitRefName), Force()GitCompleted
IGitBranchDeleteBuilderForce()GitCompleted
IGitCheckoutBuilderCreatingBranch(), Force(), Detach()GitCompleted
IGitRemoteAddBuilderWithFetch()GitCompleted
IGitRemoteRemoveBuilder(none)GitCompleted
IGitRemoteSetUrlBuilderForPushOnly()GitCompleted
IGitFetchBuilderFromRemote(GitRemoteName), AllRemotes(), Prune(), WithTags(), WithDepth(int), ReportingProgress(IProgress<string>)GitFetchResult
IGitPullBuilderFromRemote(GitRemoteName), WithBranch(GitBranchName), FastForwardOnly(), Rebase(), Prune(), ReportingProgress(IProgress<string>)GitCompleted
IGitPushBuilderToRemote(GitRemoteName), WithBranch(GitBranchName), SettingUpstream(), Force(), ForceWithLease(), DeletingRemoteBranch(), DryRun(), ReportingProgress(IProgress<string>)GitPushResult

Result and Execution Models

TypeDescription
GitOptionsConfigures the git executable path and a per-invocation timeout.
IGitProcessRunnerRuns the git executable with a given argument vector; implemented by RunCommandGitProcessRunner.
GitResult<T>The outcome of a command run with TryExecuteAsync: either Value or Error, never both.
GitCommandErrorThe exit code, argument vector, and standard error of a failed invocation.

Exceptions

TypeThrown When
GitExceptionBase type for every failure originating in this library.
GitExecutableNotFoundExceptionThe git executable could not be started.
GitTimeoutExceptionGit did not complete within the configured GitOptions.Timeout.
GitParseExceptionGit succeeded but produced output the parser could not interpret.
GitCommandExceptionGit ran and exited non-zero. Carries ExitCode, Arguments, and StandardError.
GitRepositoryNotFoundExceptionA GitCommandException specialization: the path is not inside a git working tree.
GitNothingToCommitExceptionA GitCommandException specialization: git commit was run with nothing staged. The one commit failure that is an ordinary program state rather than a fault.
GitPushRejectedExceptionA GitCommandException specialization: git refused at least one reference during push. Carries the parsed Result (GitPushResult) so the rejection detail is not lost.
GitPullConflictExceptionA GitCommandException specialization: pull left conflicts in the working tree. Use Status() to see which paths are unmerged.
GitHostingExceptionBase type for every hosting-layer failure. Does not derive from GitException — it carries HTTP concepts, not process ones. Carries ProviderName, StatusCode, ResponseBody.
GitHostingAuthenticationExceptionA GitHostingException specialization: the host rejected the request as unauthenticated, or the credential no longer grants access.
GitHostingNotFoundExceptionA GitHostingException specialization: the requested resource does not exist, or is not visible to the caller's credentials.
GitHostingRateLimitExceptionA GitHostingException specialization: the caller has exhausted its request quota. Carries ResetsAt, when the host reported one.
GitHostingRequestExceptionA GitHostingException specialization for any other non-success response — a malformed request, a validation failure, or a server-side error.

Result Models

TypeDescription
GitStatusBranch, Upstream, Ahead, Behind, IsDetached, Entries, IsClean.
GitStatusEntryIndexState, WorkTreeState, Path, OriginalPath for one changed path.
GitCommitSha, TreeSha, ParentShas, Author, Committer, Subject, Body.
GitSignatureName, Email, Timestamp recorded on a commit.
GitBranchName, Sha, Upstream, IsCurrent, IsRemote.
GitRemoteName, FetchUrl, PushUrl.
GitDiffEntryKind, Path, OriginalPath, SimilarityPercent.
GitVersionMajor, Minor, Patch, Raw, plus AtLeast(major, minor).
GitFileStateEnum: Unmodified, Modified, Added, Deleted, Renamed, Copied, Untracked, Ignored, Unmerged, TypeChanged.
GitChangeKindEnum: Added, Copied, Deleted, Modified, Renamed, TypeChanged, Unmerged, Unknown.
GitUntrackedFilesModeEnum: No, Normal, All.
GitCompletedThe result of a mutating verb whose only outcome is success — add, checkout, branch creation/deletion, remote commands, and pull. Carries Arguments.
GitInitResultRepository, AlreadyExisted — the outcome of IGitClient.Init.
GitFetchResultUpdates, DetailAvailable, IsUpToDate — the outcome of Fetch(). IsUpToDate is gated on DetailAvailable so an empty Updates from a pre-2.41 git is never mistaken for "nothing changed".
GitPushResultUpdates, HasRejections — the outcome of Push().
GitRefUpdateKind, Reference, Source, OldSha, NewSha, Summary, IsRejected — one reference changed by a fetch or a push.
GitRefUpdateKindEnum: FastForward, Forced, Removed, Created, Rejected, UpToDate, TagUpdate, Unknown.
GitPullRequestNumber, Title, Description, SourceBranch, TargetBranch, Author, State, IsDraft, WebURI, CreatedAt — one pull request, as reported by a hosting provider. A null optional field means the host did not report that value.
GitPullRequestStateEnum: Open, Merged, Closed.

IGitHostingProvider

The contract every hosting provider implements: repository enumeration, pull request listing, and pull request creation, over whichever transport and authentication scheme the host requires.

Properties

NameTypeDescription
NameGitProviderNameDisplay name of the provider.
OwnerGitProviderOwnerThe owner of the repositories in this provider.
PersonaGUIDPersonaGUIDThe persona GUID used for authentication with the provider (from ktsu.CredentialCache).
IsAuthenticatedboolWhether requests this provider issues carry a credential. A cache entry that resolves to "proceed unauthenticated", and one of a type the provider cannot apply, both report false.

Methods

NameReturn TypeDescription
GetRepositoriesAsync(CancellationToken)Task<IReadOnlyList<GitRepository>>Retrieves the repositories Owner has, from the host. Coverage differs by host — see GitHubProvider and AzureDevOpsProvider below.
GetPullRequestsAsync(GitRepositoryName, CancellationToken)Task<IReadOnlyList<GitPullRequest>>Retrieves a repository's open pull requests. The filter is requested explicitly of the host, not left to its default.
CreatePullRequest(GitRepositoryName)IGitPullRequestCreateBuilderStarts building a pull request for a repository.

GitProvider

The abstract base both hosting providers derive from. Implements IGitHostingProvider and resolves credentials via TryGetCredential/ResolveCredential; each concrete provider builds its own HttpClient-based transport around that credential — see GitHubProvider and AzureDevOpsProvider below for how the two differ. public bool TryGetCredential(out Credential?) is declared here, not on IGitHostingProvider, and reports only whether the credential cache holds an entry for this provider's PersonaGUID. IsAuthenticated is the narrower question of whether a request would actually carry one.

Each provider type keeps one shared SocketsHttpHandler for the life of the process and builds a short-lived client or adapter over it per call, so a caller looping over many repositories reuses one connection pool rather than building and tearing down one per request. Neither provider is IDisposable, and neither ever disposes a handler injected for testing.

GitHubProvider

GitProvider implementation backed by Octokit. GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones, and supplying a token does not widen this.

AzureDevOpsProvider

GitProvider implementation built on a raw HttpClient against Azure DevOps's REST API (api-version=7.1) — no Azure DevOps client library is referenced (see the Introduction). GetPullRequestsAsync pages with $top and $skip until a short page comes back, so a repository with more open pull requests than one page costs more than one request and is never truncated. GetRepositoriesAsync issues exactly one request, because that endpoint documents no pagination.

Additional Properties

NameTypeDescription
ProjectAzureDevOpsProjectName?Scopes repository enumeration to a project, or null to enumerate the whole organisation. Required for GetPullRequestsAsync and CreatePullRequest — Azure DevOps has no project-less pull request endpoint, and calling either without it throws InvalidOperationException.

GetRepositoriesAsync returns everything the resolved credential can see — unlike GitHubProvider, there is no public-only restriction.

IGitPullRequestCreateBuilder

Collects a pull request's details before submitting it. From, Into, and Titled are required; Describing and AsDraft are optional. A missing required value throws InvalidOperationException from ExecuteAsync, not from the setter that left it unset, since parts may be supplied in any order.

NameReturn TypeDescription
From(GitBranchName)IGitPullRequestCreateBuilderSets the source branch.
Into(GitBranchName)IGitPullRequestCreateBuilderSets the target branch.
Titled(GitPullRequestTitle)IGitPullRequestCreateBuilderSets the title.
Describing(string)IGitPullRequestCreateBuilderSets the description.
AsDraft()IGitPullRequestCreateBuilderMarks the pull request as a draft.
ExecuteAsync(CancellationToken)Task<GitPullRequest>Submits the pull request to the host.

ServiceCollectionExtensions

NameReturn TypeDescription
AddGitIntegration(IServiceCollection)IServiceCollectionRegisters git integration with default options, invoking the git found on PATH.
AddGitIntegration(IServiceCollection, Action<GitOptions>)IServiceCollectionRegisters git integration with configured options. Idempotent per service.

Registers only the local layer. Hosting providers are constructed directly — see Working with a Hosting Provider — since neither GitHubProvider nor AzureDevOpsProvider has a constructor dependency a container could supply.

Semantic Types

TypeWraps
GitAuthorEmailCommit author or committer email address
GitAuthorNameCommit author or committer name
GitBranchNameBranch name
GitCommitMessageCommit message
GitCommitShaCommit object id (abbreviated or full, including SHA-256 repositories)
GitProviderNameHosting provider display name
GitProviderOwnerAccount or organization owning a repository
GitRefNameA branch, tag, SHA, or revision expression
GitRemoteNameRemote name
GitRepositoryNameRepository name
GitRepositoryRemotePathClone path or URL
GitRepositoryWebURIRepository web address
AzureDevOpsProjectNameAzure DevOps project name — scopes AzureDevOpsProvider repository enumeration, and required for its pull request operations
GitPullRequestNumberPull request's host-assigned number
GitPullRequestTitlePull request title
GitPullRequestAuthorHost's identifier for the account that opened a pull request (a GitHub login, or an Azure DevOps unique name)
GitPullRequestWebURIPull request web address

Contributing

Contributions are welcome! Feel free to open issues or submit pull requests.

License

This project is licensed under the MIT License. See the LICENSE.md file for details.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

ktsu.GitIntegration

A .NET library that wraps the git binary behind a fluent, strongly-typed interface, and unifies access to hosted Git providers behind a single abstraction.

LicenseNuGet VersionNuGet VersionNuGet DownloadsGitHub commit activityGitHub contributorsGitHub Actions Workflow Status

Introduction

ktsu.GitIntegration is a two-layer library. The local layer wraps the git executable found on PATH behind a fluent, strongly-typed interface: open or discover a repository, then build and run both read-only commands (status, log, diff, branches, remotes, rev-parse) and mutating commands (init, clone, add, commit, branch creation and deletion, checkout, remote management, and remote sync via fetch, pull, and push) without shelling out or hand-parsing porcelain output yourself. The hosting layer — the original half of this library — unifies access to hosted Git providers behind a GitProvider abstraction: GitHubProvider, built on Octokit, and AzureDevOpsProvider, built on a raw HttpClient against Azure DevOps's REST API. Both enumerate repositories and list and create pull requests through the same IGitHostingProvider contract.

Every value that would otherwise be a bare string — a branch name, a commit SHA, a remote name, an author email — is instead a validated semantic type built on ktsu.Semantics, so a GitBranchName can no longer be accidentally passed where a GitCommitSha is expected.

Azure DevOps hosting deliberately does not use Microsoft.TeamFoundationServer.Client or the other official TFS/Azure DevOps client package — both pull in System.Data.SqlClient, which carries a known high-severity advisory, as a direct dependency of the published package. AzureDevOpsProvider builds and parses its requests by hand instead.

Features

  • Local Git Client: IGitClient/GitClient finds and opens repositories — GetVersionAsync, IsRepositoryAsync, OpenAsync, DiscoverAsync — and creates new ones — Init(...), Clone(...) — by delegating every invocation to ktsu.RunCommand.
  • Fluent Verb Builders: GitRepository exposes one builder per read-only verb — Status(), Log(), Diff(), Branches(), Remotes(), RevParse(...) — and one per mutating verb — Add(), Commit(...), CreateBranch(...), DeleteBranch(...), Checkout(...), AddRemote(...), RemoveRemote(...), SetRemoteUrl(...), Fetch(), Pull(), Push() — each configurable via chained method calls and run with ExecuteAsync or the non-throwing TryExecuteAsync.
  • Remote Sync: Fetch() downloads objects and refs without touching the working tree, Pull() fetches and integrates into the current branch, and Push() sends local commits — fetch and push report a machine-readable, per-reference account of what happened via GitFetchResult/GitPushResult, and a rejected push is the one place in this library where ExecuteAsync and TryExecuteAsync diverge in more than exception-versus-result.
  • Strongly-Typed Results: GitStatus, GitCommit, GitBranch, GitRemote, GitDiffEntry, GitVersion, GitInitResult, GitCompleted, GitFetchResult, GitPushResult, and GitRefUpdate records replace ad-hoc porcelain parsing with typed models — GitCompleted is the shared result for mutating verbs whose only outcome is success.
  • Reproducible Failures: every command is scoped with git -C <path> instead of a process working directory, so a failing invocation's exact argument vector can be read off a GitCommandException and rerun verbatim.
  • Locale-Safe Parsing: every invocation runs with GIT_TERMINAL_PROMPT=0 (no hanging credential prompts) and LC_ALL=C (English, machine-stable output), which is what makes the output parsers safe to write against fixed English text.
  • Dependency Injection: AddGitIntegration() registers the client, process runner, and options as singletons in one call. The hosting layer is constructed directly instead — see Working with a Hosting Provider.
  • Hosting Provider Abstraction: IGitHostingProvider defines a common contract for enumerating repositories, listing open pull requests, and creating a pull request — GitHubProvider implements it on top of Octokit, AzureDevOpsProvider on a raw HttpClient against Azure DevOps's REST API.
  • Credential Resolution: hosting providers integrate with ktsu.CredentialCache, so credentials come from the host's native keyring rather than configuration files.
  • Semantic Git Types: validated wrapper types for every identifier Git tooling passes around, so mismatched arguments fail at compile time rather than at runtime.

Installation

Package Manager Console

Install-Package ktsu.GitIntegration

.NET CLI

dotnet add package ktsu.GitIntegration

Package Reference

<PackageReferenceInclude="ktsu.GitIntegration"Version="x.y.z" />

Usage Examples

Basic Example

Register the library with dependency injection, then resolve IGitClient:

usingktsu.GitIntegration;usingMicrosoft.Extensions.DependencyInjection;ServiceCollectionservices=new();services.AddGitIntegration();usingServiceProviderprovider=services.BuildServiceProvider();IGitClientclient=provider.GetRequiredService<IGitClient>();

Discovering a Repository and Reading Its Status

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathhere=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepository?repository=awaitclient.DiscoverAsync(here);if(repositoryis not null){GitStatusstatus=awaitrepository.Status().ExecuteAsync();Console.WriteLine(status.IsClean?"Working tree is clean.":$"{status.Entries.Count} changed path(s) on {status.Branch?.WeakString}.");}

Listing Commits and Diffs

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IReadOnlyList<GitCommit>commits=awaitrepository.Log().Take(10).FirstParentOnly().ExecuteAsync();foreach(GitCommitcommitincommits){Console.WriteLine($"{commit.Sha.WeakString[..7]}{commit.Subject}");}IReadOnlyList<GitDiffEntry>changes=awaitrepository.Diff().Staged().DetectRenames().ExecuteAsync();

Initializing or Cloning a Repository

Init probes the target path before running git init, so GitInitResult.AlreadyExisted can tell a caller whether a repository was already there — git init is idempotent and silently ignores --initial-branch when re-initialising, so a caller that asked for a particular initial branch and got AlreadyExisted == true did not get the branch it asked for:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathtarget=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitInitResultinit=awaitclient.Init(target).WithInitialBranch("main".As<GitBranchName>()).ExecuteAsync();GitRepositoryrepository=init.Repository;

Clone builds git clone. Its destination pre-check is advisory only — git enforces the same rule itself, so the check exists solely to fail a doomed clone before it pays its network cost, and it is deliberately racy:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;GitRepositoryRemotePathsource="https://github.com/ktsu-dev/GitIntegration.git".As<GitRepositoryRemotePath>();AbsoluteDirectoryPathdestination=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepositorycloned=awaitclient.Clone(source,destination).WithDepth(1).ReportingProgress(newProgress<string>(line =>Console.WriteLine(line))).ExecuteAsync();

Staging and Committing Changes

Commit runs git twice: git commit itself, then git log -1 with this library's pinned format, because commit's own output is a human summary carrying only an abbreviated object id, with no machine-readable alternative:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.Add().All().ExecuteAsync();GitCommitcommit=awaitrepository.Commit("Add feature X".As<GitCommitMessage>()).WithBody("Longer explanation of the change.").WithAuthor("Ada Lovelace".As<GitAuthorName>(),"ada@example.com".As<GitAuthorEmail>()).ExecuteAsync();Console.WriteLine(commit.Sha.WeakString);

Committing with nothing staged throws GitNothingToCommitException, a GitCommandException specialization, rather than the generic base type — the one commit failure that is an ordinary program state rather than a fault:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Commit("Nothing changed".As<GitCommitMessage>()).ExecuteAsync();}catch(GitNothingToCommitException){Console.WriteLine("Nothing was staged; skipping this commit.");}

Creating Branches and Switching

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.CreateBranch("feature/new-thing".As<GitBranchName>()).StartingAt("main".As<GitRefName>()).ExecuteAsync();awaitrepository.Checkout("feature/new-thing".As<GitRefName>()).ExecuteAsync();// Later, once the branch is no longer needed:awaitrepository.DeleteBranch("feature/new-thing".As<GitBranchName>()).Force().ExecuteAsync();

Managing Remotes

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryRemotePathurl="https://github.com/example/repo.git".As<GitRepositoryRemotePath>();awaitrepository.AddRemote("upstream".As<GitRemoteName>(),url).WithFetch().ExecuteAsync();awaitrepository.SetRemoteUrl("upstream".As<GitRemoteName>(),url).ForPushOnly().ExecuteAsync();awaitrepository.RemoveRemote("upstream".As<GitRemoteName>()).ExecuteAsync();

Fetching

fetch --porcelain is only available from git 2.41 onward, so Fetch() probes the installed git's version first. Below that threshold the fetch still runs and still succeeds, but GitFetchResult.DetailAvailable is false and Updates is empty — check DetailAvailable before trusting an empty Updates as "nothing changed":

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitFetchResultfetched=awaitrepository.Fetch().FromRemote("origin".As<GitRemoteName>()).Prune().WithTags().ExecuteAsync();if(fetched.DetailAvailable){Console.WriteLine(fetched.IsUpToDate?"Already up to date.":$"{fetched.Updates.Count} reference(s) updated.");}else{Console.WriteLine("Fetch completed, but this git is older than 2.41 so no per-reference detail is available.");}

Pulling

Pull() returns GitCompleted rather than a parsed result, because everything git pull prints is human prose with no porcelain form. Use Status() and Log() afterwards to learn what changed. A merge conflict is the one outcome with its own exception, GitPullConflictException, because it leaves the repository mid-merge:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Pull().FromRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).FastForwardOnly().ExecuteAsync();}catch(GitPullConflictException){GitStatusstatus=awaitrepository.Status().ExecuteAsync();IEnumerable<GitStatusEntry>unmerged=status.Entries.Where(e =>e.IndexState==GitFileState.Unmerged);Console.WriteLine($"Pull left {unmerged.Count()} unmerged path(s); resolve them and commit.");}

FastForwardOnly() and Rebase() are mutually exclusive — combining them throws InvalidOperationException when the argument vector is built, since they mean opposite things about history.

Pushing — Why ExecuteAsync and TryExecuteAsync Disagree

push is the one verb in this library where the two entry points mean genuinely different things, not just exception-versus-result. A rejected push exits non-zero from git and prints a complete porcelain record of every reference — git got far enough to talk about them and refused some. A caller who does not know this will get it wrong by assuming TryExecuteAsync returning Success means the push landed:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;// ExecuteAsync stays strict: a rejection throws, and the exception carries the full parsed result.try{GitPushResultpushed=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).SettingUpstream().ExecuteAsync();}catch(GitPushRejectedExceptionex){// ex.Result is the same GitPushResult a successful push would have returned.foreach(GitRefUpdateupdateinex.Result!.Updates.Where(u =>u.IsRejected)){Console.WriteLine($"{update.Reference.WeakString}: {update.Summary}");}}

TryExecuteAsync does not throw for a rejection — git ran and reported exactly what happened, so that report comes back as a successful GitResult. Always check HasRejections, not just Success:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitPushResult>result=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).TryExecuteAsync();if(result.Success&&result.Value!.HasRejections){// This is still result.Success == true: TryExecuteAsync only fails when git never reached// the remote at all. A rejection is reported as data, not as GitResult failure.Console.WriteLine("Push ran but at least one reference was rejected — check result.Value.Updates.");}

ForceWithLease() wins over Force() when both are set, being the safer of the two — it refuses if the remote moved since it was last fetched.

Resolving a Revision Without Throwing

TryExecuteAsync reports a non-zero exit as a result instead of an exception — useful when "no such revision" is an expected outcome rather than a failure:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitCommitSha>result=awaitrepository.RevParse("maybe-missing-branch".As<GitRefName>()).TryExecuteAsync();if(result.Success){Console.WriteLine(result.Value!.WeakString);}else{Console.WriteLine($"git exited {result.Error!.ExitCode}: {result.Error.StandardError}");}

Reproducing a Failing Command

ExecuteAsync throws GitCommandException on a non-zero exit, carrying the exact argument vector git was invoked with:

try{awaitrepository.RevParse("no-such-ref".As<GitRefName>()).ExecuteAsync();}catch(GitCommandExceptionex){// ex.Arguments already begins with "-C <path>", so this can be pasted straight after `git`// on a command line to reproduce the failure exactly.Console.WriteLine("git "+string.Join(' ',ex.Arguments));}

Working with a Hosting Provider

Providers are constructed directly rather than resolved from DI: GitHubProvider and AzureDevOpsProvider need only a caller-supplied Owner (and, for Azure DevOps, an optional Project), so there is nothing a container would meaningfully wire up.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProvidergithub=newGitHubProvider{Owner="ktsu-dev".As<GitProviderOwner>(),};// Credentials come from ktsu.CredentialCache, keyed by PersonaGUID — nothing to configure here// unless a specific persona is needed.IReadOnlyList<GitRepository>repositories=awaitgithub.GetRepositoriesAsync();

GitHubProvider.GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones. Azure DevOps has no equivalent restriction: it returns everything the resolved credential can see.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProviderazure=newAzureDevOpsProvider{Owner="my-org".As<GitProviderOwner>(),Project="my-project".As<AzureDevOpsProjectName>(),};IReadOnlyList<GitRepository>repositories=awaitazure.GetRepositoriesAsync();

Project is only required for pull request operations — Azure DevOps has no project-less pull request endpoint, and calling GetPullRequestsAsync or CreatePullRequest without it throws InvalidOperationException immediately. GetPullRequestsAsync returns open pull requests only, on both hosts:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryNamerepositoryName="GitIntegration".As<GitRepositoryName>();IReadOnlyList<GitPullRequest>openPullRequests=awaitazure.GetPullRequestsAsync(repositoryName);foreach(GitPullRequestpullRequestinopenPullRequests){Console.WriteLine($"#{pullRequest.Number.WeakString}{pullRequest.Title.WeakString} ({pullRequest.State})");}

Creating a pull request goes through a builder, the same idiom as the local layer's mutating verbs:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitPullRequestcreated=awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).Describing("Longer explanation of the change.").ExecuteAsync();Console.WriteLine(created.WebURI?.WeakString);

A hosting failure — authentication, not found, rate limiting, or anything else the host reports — surfaces as a GitHostingException subtype carrying the provider name, HTTP status, and response body, mirroring how GitCommandException carries the argument vector for the local layer:

try{awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).ExecuteAsync();}catch(GitHostingAuthenticationExceptionex){Console.WriteLine($"{ex.ProviderName} rejected the request: {ex.StatusCode}");}catch(GitHostingRateLimitExceptionex){Console.WriteLine($"Rate limited until {ex.ResetsAt}.");}

Working with Semantic Types

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitBranchNamebranch="main".As<GitBranchName>();GitRemoteNameremote="origin".As<GitRemoteName>();GitCommitShasha="9fceb02d0ae598e95dc970b74767f19372d61af8".As<GitCommitSha>();// These are distinct types — passing a GitBranchName where a GitCommitSha// is expected is a compile error, not a runtime surprise.

Advanced Usage

Argument Vectors Are Inspectable Before They Run

Every builder's BuildArguments() is a pure computation with no I/O, so the exact command can be asserted or logged before it executes:

IReadOnlyList<string>arguments=repository.Status().BuildArguments();// ["-C", "<path>", "--no-pager", "-c", "core.quotepath=false", "-c", "color.ui=false",// "status", "--porcelain=v2", "--branch", "-z"]

Metadata-Only Repositories

A GitRepository produced by a hosting provider (rather than IGitClient.OpenAsync or DiscoverAsync) carries hosting metadata but no ProcessRunner. Calling any verb on it throws InvalidOperationException immediately, rather than failing later inside git:

GitRepositorymetadataOnly=new(){LocalPath=somePath,Name="GitIntegration".As<GitRepositoryName>()};// Throws InvalidOperationException — obtain a runnable repository from IGitClient first._=metadataOnly.Status();

API Reference

IGitClient / GitClient

The entry point to the local layer: finds and opens repositories, and reports on the git binary.

Methods

NameReturn TypeDescription
GetVersionAsync(CancellationToken)Task<GitVersion>Reports the version of the git binary being invoked.
IsRepositoryAsync(AbsoluteDirectoryPath, CancellationToken)Task<bool>Decides whether a path is inside a git working tree. Never throws for a non-repository path.
OpenAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository>Opens the repository containing a path. Throws GitRepositoryNotFoundException when there is none.
DiscoverAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository?>Opens the repository containing a path, returning null instead of throwing when there is none.
Init(AbsoluteDirectoryPath)IGitInitBuilderCreates a repository at a path. Probes first, so the result's AlreadyExisted can tell a caller whether one was already there.
Clone(GitRepositoryRemotePath, AbsoluteDirectoryPath)IGitCloneBuilderClones a repository into a local working copy.
Clone(GitRepository)IGitCloneBuilderClones the repository a hosting provider described, using its RemotePath and intended LocalPath.

GitRepository

Carries LocalPath plus optional hosting metadata, and exposes one builder factory per verb.

Properties

NameTypeDescription
LocalPathAbsoluteDirectoryPathThe working tree's local filesystem path.
NameGitRepositoryName?The repository name, when known.
WebURIGitRepositoryWebURI?The browser-facing URI, when known.
RemotePathGitRepositoryRemotePath?The remote clone path, when known.
ProcessRunnerIGitProcessRunner?The runner this repository's verbs execute through; null on a metadata-only repository.

Methods

NameReturn TypeDescription
Status()IGitStatusBuilderBuilds git status --porcelain=v2 --branch -z.
Log()IGitLogBuilderBuilds git log -z with this library's pinned format.
Diff()IGitDiffBuilderBuilds git diff --name-status -z.
Branches()IGitBranchListBuilderBuilds git for-each-ref over the branch namespaces.
Remotes()IGitRemoteListBuilderBuilds git remote -v.
RevParse(GitRefName)IGitRevParseBuilderBuilds git rev-parse --verify for a revision.
Add()IGitAddBuilderBuilds git add.
Commit(GitCommitMessage)IGitCommitBuilderBuilds git commit, then reads the new commit back with git log -1.
CreateBranch(GitBranchName)IGitBranchCreateBuilderBuilds git branch <name> [<start-point>].
DeleteBranch(GitBranchName)IGitBranchDeleteBuilderBuilds git branch --delete <name>.
Checkout(GitRefName)IGitCheckoutBuilderBuilds git checkout.
AddRemote(GitRemoteName, GitRepositoryRemotePath)IGitRemoteAddBuilderBuilds git remote add <name> <url>.
RemoveRemote(GitRemoteName)IGitRemoteRemoveBuilderBuilds git remote remove <name>.
SetRemoteUrl(GitRemoteName, GitRepositoryRemotePath)IGitRemoteSetUrlBuilderBuilds git remote set-url <name> <url>.
Fetch()IGitFetchBuilderBuilds git fetch, with --porcelain where the installed git supports it.
Pull()IGitPullBuilderBuilds git pull.
Push()IGitPushBuilderBuilds git push --porcelain.
IsClonedAsync(CancellationToken)Task<bool>Decides whether LocalPath currently holds a git working tree.
OpenWebClient()voidOpens WebURI in the default browser, when it is an absolute http/https URI.

IGitCommandBuilder<TResult>

The shared contract every verb builder implements. A builder is single-use and not thread-safe.

Methods

NameReturn TypeDescription
BuildArguments()IReadOnlyList<string>The exact argument vector this builder will pass to git. Pure, no I/O.
ExecuteAsync(CancellationToken)Task<TResult>Runs the command, throwing GitCommandException when git exits non-zero.
TryExecuteAsync(CancellationToken)Task<GitResult<TResult>>Runs the command, reporting a non-zero exit as a result instead of throwing.

Verb Builders

InterfaceExtra MethodsResult
IGitStatusBuilderWithUntrackedFiles(GitUntrackedFilesMode), IncludeIgnored()GitStatus
IGitLogBuilderTake(int), Skip(int), ForRevision(GitRefName), ForPath(RelativeFilePath), FirstParentOnly()IReadOnlyList<GitCommit>
IGitDiffBuilderStaged(), Against(GitRefName), Between(GitRefName, GitRefName), DetectRenames(), DetectCopies(), ForPath(RelativeFilePath)IReadOnlyList<GitDiffEntry>
IGitBranchListBuilderLocalOnly(), RemoteOnly()IReadOnlyList<GitBranch>
IGitRemoteListBuilder(none)IReadOnlyList<GitRemote>
IGitRevParseBuilder(none — revision supplied via GitRepository.RevParse)GitCommitSha
IGitInitBuilderBare(), WithInitialBranch(GitBranchName)GitInitResult
IGitCloneBuilderWithBranch(GitBranchName), WithDepth(int), Bare(), ReportingProgress(IProgress<string>)GitRepository
IGitAddBuilderForPath(RelativeFilePath), All(), UpdateTrackedOnly()GitCompleted
IGitCommitBuilderWithBody(string), AllowEmpty(), StageTrackedFiles(), WithAuthor(GitAuthorName, GitAuthorEmail)GitCommit
IGitBranchCreateBuilderStartingAt(GitRefName), Force()GitCompleted
IGitBranchDeleteBuilderForce()GitCompleted
IGitCheckoutBuilderCreatingBranch(), Force(), Detach()GitCompleted
IGitRemoteAddBuilderWithFetch()GitCompleted
IGitRemoteRemoveBuilder(none)GitCompleted
IGitRemoteSetUrlBuilderForPushOnly()GitCompleted
IGitFetchBuilderFromRemote(GitRemoteName), AllRemotes(), Prune(), WithTags(), WithDepth(int), ReportingProgress(IProgress<string>)GitFetchResult
IGitPullBuilderFromRemote(GitRemoteName), WithBranch(GitBranchName), FastForwardOnly(), Rebase(), Prune(), ReportingProgress(IProgress<string>)GitCompleted
IGitPushBuilderToRemote(GitRemoteName), WithBranch(GitBranchName), SettingUpstream(), Force(), ForceWithLease(), DeletingRemoteBranch(), DryRun(), ReportingProgress(IProgress<string>)GitPushResult

Result and Execution Models

TypeDescription
GitOptionsConfigures the git executable path and a per-invocation timeout.
IGitProcessRunnerRuns the git executable with a given argument vector; implemented by RunCommandGitProcessRunner.
GitResult<T>The outcome of a command run with TryExecuteAsync: either Value or Error, never both.
GitCommandErrorThe exit code, argument vector, and standard error of a failed invocation.

Exceptions

TypeThrown When
GitExceptionBase type for every failure originating in this library.
GitExecutableNotFoundExceptionThe git executable could not be started.
GitTimeoutExceptionGit did not complete within the configured GitOptions.Timeout.
GitParseExceptionGit succeeded but produced output the parser could not interpret.
GitCommandExceptionGit ran and exited non-zero. Carries ExitCode, Arguments, and StandardError.
GitRepositoryNotFoundExceptionA GitCommandException specialization: the path is not inside a git working tree.
GitNothingToCommitExceptionA GitCommandException specialization: git commit was run with nothing staged. The one commit failure that is an ordinary program state rather than a fault.
GitPushRejectedExceptionA GitCommandException specialization: git refused at least one reference during push. Carries the parsed Result (GitPushResult) so the rejection detail is not lost.
GitPullConflictExceptionA GitCommandException specialization: pull left conflicts in the working tree. Use Status() to see which paths are unmerged.
GitHostingExceptionBase type for every hosting-layer failure. Does not derive from GitException — it carries HTTP concepts, not process ones. Carries ProviderName, StatusCode, ResponseBody.
GitHostingAuthenticationExceptionA GitHostingException specialization: the host rejected the request as unauthenticated, or the credential no longer grants access.
GitHostingNotFoundExceptionA GitHostingException specialization: the requested resource does not exist, or is not visible to the caller's credentials.
GitHostingRateLimitExceptionA GitHostingException specialization: the caller has exhausted its request quota. Carries ResetsAt, when the host reported one.
GitHostingRequestExceptionA GitHostingException specialization for any other non-success response — a malformed request, a validation failure, or a server-side error.

Result Models

TypeDescription
GitStatusBranch, Upstream, Ahead, Behind, IsDetached, Entries, IsClean.
GitStatusEntryIndexState, WorkTreeState, Path, OriginalPath for one changed path.
GitCommitSha, TreeSha, ParentShas, Author, Committer, Subject, Body.
GitSignatureName, Email, Timestamp recorded on a commit.
GitBranchName, Sha, Upstream, IsCurrent, IsRemote.
GitRemoteName, FetchUrl, PushUrl.
GitDiffEntryKind, Path, OriginalPath, SimilarityPercent.
GitVersionMajor, Minor, Patch, Raw, plus AtLeast(major, minor).
GitFileStateEnum: Unmodified, Modified, Added, Deleted, Renamed, Copied, Untracked, Ignored, Unmerged, TypeChanged.
GitChangeKindEnum: Added, Copied, Deleted, Modified, Renamed, TypeChanged, Unmerged, Unknown.
GitUntrackedFilesModeEnum: No, Normal, All.
GitCompletedThe result of a mutating verb whose only outcome is success — add, checkout, branch creation/deletion, remote commands, and pull. Carries Arguments.
GitInitResultRepository, AlreadyExisted — the outcome of IGitClient.Init.
GitFetchResultUpdates, DetailAvailable, IsUpToDate — the outcome of Fetch(). IsUpToDate is gated on DetailAvailable so an empty Updates from a pre-2.41 git is never mistaken for "nothing changed".
GitPushResultUpdates, HasRejections — the outcome of Push().
GitRefUpdateKind, Reference, Source, OldSha, NewSha, Summary, IsRejected — one reference changed by a fetch or a push.
GitRefUpdateKindEnum: FastForward, Forced, Removed, Created, Rejected, UpToDate, TagUpdate, Unknown.
GitPullRequestNumber, Title, Description, SourceBranch, TargetBranch, Author, State, IsDraft, WebURI, CreatedAt — one pull request, as reported by a hosting provider. A null optional field means the host did not report that value.
GitPullRequestStateEnum: Open, Merged, Closed.

IGitHostingProvider

The contract every hosting provider implements: repository enumeration, pull request listing, and pull request creation, over whichever transport and authentication scheme the host requires.

Properties

NameTypeDescription
NameGitProviderNameDisplay name of the provider.
OwnerGitProviderOwnerThe owner of the repositories in this provider.
PersonaGUIDPersonaGUIDThe persona GUID used for authentication with the provider (from ktsu.CredentialCache).
IsAuthenticatedboolWhether requests this provider issues carry a credential. A cache entry that resolves to "proceed unauthenticated", and one of a type the provider cannot apply, both report false.

Methods

NameReturn TypeDescription
GetRepositoriesAsync(CancellationToken)Task<IReadOnlyList<GitRepository>>Retrieves the repositories Owner has, from the host. Coverage differs by host — see GitHubProvider and AzureDevOpsProvider below.
GetPullRequestsAsync(GitRepositoryName, CancellationToken)Task<IReadOnlyList<GitPullRequest>>Retrieves a repository's open pull requests. The filter is requested explicitly of the host, not left to its default.
CreatePullRequest(GitRepositoryName)IGitPullRequestCreateBuilderStarts building a pull request for a repository.

GitProvider

The abstract base both hosting providers derive from. Implements IGitHostingProvider and resolves credentials via TryGetCredential/ResolveCredential; each concrete provider builds its own HttpClient-based transport around that credential — see GitHubProvider and AzureDevOpsProvider below for how the two differ. public bool TryGetCredential(out Credential?) is declared here, not on IGitHostingProvider, and reports only whether the credential cache holds an entry for this provider's PersonaGUID. IsAuthenticated is the narrower question of whether a request would actually carry one.

Each provider type keeps one shared SocketsHttpHandler for the life of the process and builds a short-lived client or adapter over it per call, so a caller looping over many repositories reuses one connection pool rather than building and tearing down one per request. Neither provider is IDisposable, and neither ever disposes a handler injected for testing.

GitHubProvider

GitProvider implementation backed by Octokit. GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones, and supplying a token does not widen this.

AzureDevOpsProvider

GitProvider implementation built on a raw HttpClient against Azure DevOps's REST API (api-version=7.1) — no Azure DevOps client library is referenced (see the Introduction). GetPullRequestsAsync pages with $top and $skip until a short page comes back, so a repository with more open pull requests than one page costs more than one request and is never truncated. GetRepositoriesAsync issues exactly one request, because that endpoint documents no pagination.

Additional Properties

NameTypeDescription
ProjectAzureDevOpsProjectName?Scopes repository enumeration to a project, or null to enumerate the whole organisation. Required for GetPullRequestsAsync and CreatePullRequest — Azure DevOps has no project-less pull request endpoint, and calling either without it throws InvalidOperationException.

GetRepositoriesAsync returns everything the resolved credential can see — unlike GitHubProvider, there is no public-only restriction.

IGitPullRequestCreateBuilder

Collects a pull request's details before submitting it. From, Into, and Titled are required; Describing and AsDraft are optional. A missing required value throws InvalidOperationException from ExecuteAsync, not from the setter that left it unset, since parts may be supplied in any order.

NameReturn TypeDescription
From(GitBranchName)IGitPullRequestCreateBuilderSets the source branch.
Into(GitBranchName)IGitPullRequestCreateBuilderSets the target branch.
Titled(GitPullRequestTitle)IGitPullRequestCreateBuilderSets the title.
Describing(string)IGitPullRequestCreateBuilderSets the description.
AsDraft()IGitPullRequestCreateBuilderMarks the pull request as a draft.
ExecuteAsync(CancellationToken)Task<GitPullRequest>Submits the pull request to the host.

ServiceCollectionExtensions

NameReturn TypeDescription
AddGitIntegration(IServiceCollection)IServiceCollectionRegisters git integration with default options, invoking the git found on PATH.
AddGitIntegration(IServiceCollection, Action<GitOptions>)IServiceCollectionRegisters git integration with configured options. Idempotent per service.

Registers only the local layer. Hosting providers are constructed directly — see Working with a Hosting Provider — since neither GitHubProvider nor AzureDevOpsProvider has a constructor dependency a container could supply.

Semantic Types

TypeWraps
GitAuthorEmailCommit author or committer email address
GitAuthorNameCommit author or committer name
GitBranchNameBranch name
GitCommitMessageCommit message
GitCommitShaCommit object id (abbreviated or full, including SHA-256 repositories)
GitProviderNameHosting provider display name
GitProviderOwnerAccount or organization owning a repository
GitRefNameA branch, tag, SHA, or revision expression
GitRemoteNameRemote name
GitRepositoryNameRepository name
GitRepositoryRemotePathClone path or URL
GitRepositoryWebURIRepository web address
AzureDevOpsProjectNameAzure DevOps project name — scopes AzureDevOpsProvider repository enumeration, and required for its pull request operations
GitPullRequestNumberPull request's host-assigned number
GitPullRequestTitlePull request title
GitPullRequestAuthorHost's identifier for the account that opened a pull request (a GitHub login, or an Azure DevOps unique name)
GitPullRequestWebURIPull request web address

Contributing

Contributions are welcome! Feel free to open issues or submit pull requests.

License

This project is licensed under the MIT License. See the LICENSE.md file for details.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ktsu.GitIntegration

A .NET library that wraps the git binary behind a fluent, strongly-typed interface, and unifies access to hosted Git providers behind a single abstraction.

LicenseNuGet VersionNuGet VersionNuGet DownloadsGitHub commit activityGitHub contributorsGitHub Actions Workflow Status

Introduction

ktsu.GitIntegration is a two-layer library. The local layer wraps the git executable found on PATH behind a fluent, strongly-typed interface: open or discover a repository, then build and run both read-only commands (status, log, diff, branches, remotes, rev-parse) and mutating commands (init, clone, add, commit, branch creation and deletion, checkout, remote management, and remote sync via fetch, pull, and push) without shelling out or hand-parsing porcelain output yourself. The hosting layer — the original half of this library — unifies access to hosted Git providers behind a GitProvider abstraction: GitHubProvider, built on Octokit, and AzureDevOpsProvider, built on a raw HttpClient against Azure DevOps's REST API. Both enumerate repositories and list and create pull requests through the same IGitHostingProvider contract.

Every value that would otherwise be a bare string — a branch name, a commit SHA, a remote name, an author email — is instead a validated semantic type built on ktsu.Semantics, so a GitBranchName can no longer be accidentally passed where a GitCommitSha is expected.

Azure DevOps hosting deliberately does not use Microsoft.TeamFoundationServer.Client or the other official TFS/Azure DevOps client package — both pull in System.Data.SqlClient, which carries a known high-severity advisory, as a direct dependency of the published package. AzureDevOpsProvider builds and parses its requests by hand instead.

Features

  • Local Git Client: IGitClient/GitClient finds and opens repositories — GetVersionAsync, IsRepositoryAsync, OpenAsync, DiscoverAsync — and creates new ones — Init(...), Clone(...) — by delegating every invocation to ktsu.RunCommand.
  • Fluent Verb Builders: GitRepository exposes one builder per read-only verb — Status(), Log(), Diff(), Branches(), Remotes(), RevParse(...) — and one per mutating verb — Add(), Commit(...), CreateBranch(...), DeleteBranch(...), Checkout(...), AddRemote(...), RemoveRemote(...), SetRemoteUrl(...), Fetch(), Pull(), Push() — each configurable via chained method calls and run with ExecuteAsync or the non-throwing TryExecuteAsync.
  • Remote Sync: Fetch() downloads objects and refs without touching the working tree, Pull() fetches and integrates into the current branch, and Push() sends local commits — fetch and push report a machine-readable, per-reference account of what happened via GitFetchResult/GitPushResult, and a rejected push is the one place in this library where ExecuteAsync and TryExecuteAsync diverge in more than exception-versus-result.
  • Strongly-Typed Results: GitStatus, GitCommit, GitBranch, GitRemote, GitDiffEntry, GitVersion, GitInitResult, GitCompleted, GitFetchResult, GitPushResult, and GitRefUpdate records replace ad-hoc porcelain parsing with typed models — GitCompleted is the shared result for mutating verbs whose only outcome is success.
  • Reproducible Failures: every command is scoped with git -C <path> instead of a process working directory, so a failing invocation's exact argument vector can be read off a GitCommandException and rerun verbatim.
  • Locale-Safe Parsing: every invocation runs with GIT_TERMINAL_PROMPT=0 (no hanging credential prompts) and LC_ALL=C (English, machine-stable output), which is what makes the output parsers safe to write against fixed English text.
  • Dependency Injection: AddGitIntegration() registers the client, process runner, and options as singletons in one call. The hosting layer is constructed directly instead — see Working with a Hosting Provider.
  • Hosting Provider Abstraction: IGitHostingProvider defines a common contract for enumerating repositories, listing open pull requests, and creating a pull request — GitHubProvider implements it on top of Octokit, AzureDevOpsProvider on a raw HttpClient against Azure DevOps's REST API.
  • Credential Resolution: hosting providers integrate with ktsu.CredentialCache, so credentials come from the host's native keyring rather than configuration files.
  • Semantic Git Types: validated wrapper types for every identifier Git tooling passes around, so mismatched arguments fail at compile time rather than at runtime.

Installation

Package Manager Console

Install-Package ktsu.GitIntegration

.NET CLI

dotnet add package ktsu.GitIntegration

Package Reference

<PackageReferenceInclude="ktsu.GitIntegration"Version="x.y.z" />

Usage Examples

Basic Example

Register the library with dependency injection, then resolve IGitClient:

usingktsu.GitIntegration;usingMicrosoft.Extensions.DependencyInjection;ServiceCollectionservices=new();services.AddGitIntegration();usingServiceProviderprovider=services.BuildServiceProvider();IGitClientclient=provider.GetRequiredService<IGitClient>();

Discovering a Repository and Reading Its Status

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathhere=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepository?repository=awaitclient.DiscoverAsync(here);if(repositoryis not null){GitStatusstatus=awaitrepository.Status().ExecuteAsync();Console.WriteLine(status.IsClean?"Working tree is clean.":$"{status.Entries.Count} changed path(s) on {status.Branch?.WeakString}.");}

Listing Commits and Diffs

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IReadOnlyList<GitCommit>commits=awaitrepository.Log().Take(10).FirstParentOnly().ExecuteAsync();foreach(GitCommitcommitincommits){Console.WriteLine($"{commit.Sha.WeakString[..7]}{commit.Subject}");}IReadOnlyList<GitDiffEntry>changes=awaitrepository.Diff().Staged().DetectRenames().ExecuteAsync();

Initializing or Cloning a Repository

Init probes the target path before running git init, so GitInitResult.AlreadyExisted can tell a caller whether a repository was already there — git init is idempotent and silently ignores --initial-branch when re-initialising, so a caller that asked for a particular initial branch and got AlreadyExisted == true did not get the branch it asked for:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathtarget=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitInitResultinit=awaitclient.Init(target).WithInitialBranch("main".As<GitBranchName>()).ExecuteAsync();GitRepositoryrepository=init.Repository;

Clone builds git clone. Its destination pre-check is advisory only — git enforces the same rule itself, so the check exists solely to fail a doomed clone before it pays its network cost, and it is deliberately racy:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;GitRepositoryRemotePathsource="https://github.com/ktsu-dev/GitIntegration.git".As<GitRepositoryRemotePath>();AbsoluteDirectoryPathdestination=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepositorycloned=awaitclient.Clone(source,destination).WithDepth(1).ReportingProgress(newProgress<string>(line =>Console.WriteLine(line))).ExecuteAsync();

Staging and Committing Changes

Commit runs git twice: git commit itself, then git log -1 with this library's pinned format, because commit's own output is a human summary carrying only an abbreviated object id, with no machine-readable alternative:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.Add().All().ExecuteAsync();GitCommitcommit=awaitrepository.Commit("Add feature X".As<GitCommitMessage>()).WithBody("Longer explanation of the change.").WithAuthor("Ada Lovelace".As<GitAuthorName>(),"ada@example.com".As<GitAuthorEmail>()).ExecuteAsync();Console.WriteLine(commit.Sha.WeakString);

Committing with nothing staged throws GitNothingToCommitException, a GitCommandException specialization, rather than the generic base type — the one commit failure that is an ordinary program state rather than a fault:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Commit("Nothing changed".As<GitCommitMessage>()).ExecuteAsync();}catch(GitNothingToCommitException){Console.WriteLine("Nothing was staged; skipping this commit.");}

Creating Branches and Switching

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.CreateBranch("feature/new-thing".As<GitBranchName>()).StartingAt("main".As<GitRefName>()).ExecuteAsync();awaitrepository.Checkout("feature/new-thing".As<GitRefName>()).ExecuteAsync();// Later, once the branch is no longer needed:awaitrepository.DeleteBranch("feature/new-thing".As<GitBranchName>()).Force().ExecuteAsync();

Managing Remotes

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryRemotePathurl="https://github.com/example/repo.git".As<GitRepositoryRemotePath>();awaitrepository.AddRemote("upstream".As<GitRemoteName>(),url).WithFetch().ExecuteAsync();awaitrepository.SetRemoteUrl("upstream".As<GitRemoteName>(),url).ForPushOnly().ExecuteAsync();awaitrepository.RemoveRemote("upstream".As<GitRemoteName>()).ExecuteAsync();

Fetching

fetch --porcelain is only available from git 2.41 onward, so Fetch() probes the installed git's version first. Below that threshold the fetch still runs and still succeeds, but GitFetchResult.DetailAvailable is false and Updates is empty — check DetailAvailable before trusting an empty Updates as "nothing changed":

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitFetchResultfetched=awaitrepository.Fetch().FromRemote("origin".As<GitRemoteName>()).Prune().WithTags().ExecuteAsync();if(fetched.DetailAvailable){Console.WriteLine(fetched.IsUpToDate?"Already up to date.":$"{fetched.Updates.Count} reference(s) updated.");}else{Console.WriteLine("Fetch completed, but this git is older than 2.41 so no per-reference detail is available.");}

Pulling

Pull() returns GitCompleted rather than a parsed result, because everything git pull prints is human prose with no porcelain form. Use Status() and Log() afterwards to learn what changed. A merge conflict is the one outcome with its own exception, GitPullConflictException, because it leaves the repository mid-merge:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Pull().FromRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).FastForwardOnly().ExecuteAsync();}catch(GitPullConflictException){GitStatusstatus=awaitrepository.Status().ExecuteAsync();IEnumerable<GitStatusEntry>unmerged=status.Entries.Where(e =>e.IndexState==GitFileState.Unmerged);Console.WriteLine($"Pull left {unmerged.Count()} unmerged path(s); resolve them and commit.");}

FastForwardOnly() and Rebase() are mutually exclusive — combining them throws InvalidOperationException when the argument vector is built, since they mean opposite things about history.

Pushing — Why ExecuteAsync and TryExecuteAsync Disagree

push is the one verb in this library where the two entry points mean genuinely different things, not just exception-versus-result. A rejected push exits non-zero from git and prints a complete porcelain record of every reference — git got far enough to talk about them and refused some. A caller who does not know this will get it wrong by assuming TryExecuteAsync returning Success means the push landed:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;// ExecuteAsync stays strict: a rejection throws, and the exception carries the full parsed result.try{GitPushResultpushed=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).SettingUpstream().ExecuteAsync();}catch(GitPushRejectedExceptionex){// ex.Result is the same GitPushResult a successful push would have returned.foreach(GitRefUpdateupdateinex.Result!.Updates.Where(u =>u.IsRejected)){Console.WriteLine($"{update.Reference.WeakString}: {update.Summary}");}}

TryExecuteAsync does not throw for a rejection — git ran and reported exactly what happened, so that report comes back as a successful GitResult. Always check HasRejections, not just Success:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitPushResult>result=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).TryExecuteAsync();if(result.Success&&result.Value!.HasRejections){// This is still result.Success == true: TryExecuteAsync only fails when git never reached// the remote at all. A rejection is reported as data, not as GitResult failure.Console.WriteLine("Push ran but at least one reference was rejected — check result.Value.Updates.");}

ForceWithLease() wins over Force() when both are set, being the safer of the two — it refuses if the remote moved since it was last fetched.

Resolving a Revision Without Throwing

TryExecuteAsync reports a non-zero exit as a result instead of an exception — useful when "no such revision" is an expected outcome rather than a failure:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitCommitSha>result=awaitrepository.RevParse("maybe-missing-branch".As<GitRefName>()).TryExecuteAsync();if(result.Success){Console.WriteLine(result.Value!.WeakString);}else{Console.WriteLine($"git exited {result.Error!.ExitCode}: {result.Error.StandardError}");}

Reproducing a Failing Command

ExecuteAsync throws GitCommandException on a non-zero exit, carrying the exact argument vector git was invoked with:

try{awaitrepository.RevParse("no-such-ref".As<GitRefName>()).ExecuteAsync();}catch(GitCommandExceptionex){// ex.Arguments already begins with "-C <path>", so this can be pasted straight after `git`// on a command line to reproduce the failure exactly.Console.WriteLine("git "+string.Join(' ',ex.Arguments));}

Working with a Hosting Provider

Providers are constructed directly rather than resolved from DI: GitHubProvider and AzureDevOpsProvider need only a caller-supplied Owner (and, for Azure DevOps, an optional Project), so there is nothing a container would meaningfully wire up.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProvidergithub=newGitHubProvider{Owner="ktsu-dev".As<GitProviderOwner>(),};// Credentials come from ktsu.CredentialCache, keyed by PersonaGUID — nothing to configure here// unless a specific persona is needed.IReadOnlyList<GitRepository>repositories=awaitgithub.GetRepositoriesAsync();

GitHubProvider.GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones. Azure DevOps has no equivalent restriction: it returns everything the resolved credential can see.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProviderazure=newAzureDevOpsProvider{Owner="my-org".As<GitProviderOwner>(),Project="my-project".As<AzureDevOpsProjectName>(),};IReadOnlyList<GitRepository>repositories=awaitazure.GetRepositoriesAsync();

Project is only required for pull request operations — Azure DevOps has no project-less pull request endpoint, and calling GetPullRequestsAsync or CreatePullRequest without it throws InvalidOperationException immediately. GetPullRequestsAsync returns open pull requests only, on both hosts:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryNamerepositoryName="GitIntegration".As<GitRepositoryName>();IReadOnlyList<GitPullRequest>openPullRequests=awaitazure.GetPullRequestsAsync(repositoryName);foreach(GitPullRequestpullRequestinopenPullRequests){Console.WriteLine($"#{pullRequest.Number.WeakString}{pullRequest.Title.WeakString} ({pullRequest.State})");}

Creating a pull request goes through a builder, the same idiom as the local layer's mutating verbs:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitPullRequestcreated=awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).Describing("Longer explanation of the change.").ExecuteAsync();Console.WriteLine(created.WebURI?.WeakString);

A hosting failure — authentication, not found, rate limiting, or anything else the host reports — surfaces as a GitHostingException subtype carrying the provider name, HTTP status, and response body, mirroring how GitCommandException carries the argument vector for the local layer:

try{awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).ExecuteAsync();}catch(GitHostingAuthenticationExceptionex){Console.WriteLine($"{ex.ProviderName} rejected the request: {ex.StatusCode}");}catch(GitHostingRateLimitExceptionex){Console.WriteLine($"Rate limited until {ex.ResetsAt}.");}

Working with Semantic Types

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitBranchNamebranch="main".As<GitBranchName>();GitRemoteNameremote="origin".As<GitRemoteName>();GitCommitShasha="9fceb02d0ae598e95dc970b74767f19372d61af8".As<GitCommitSha>();// These are distinct types — passing a GitBranchName where a GitCommitSha// is expected is a compile error, not a runtime surprise.

Advanced Usage

Argument Vectors Are Inspectable Before They Run

Every builder's BuildArguments() is a pure computation with no I/O, so the exact command can be asserted or logged before it executes:

IReadOnlyList<string>arguments=repository.Status().BuildArguments();// ["-C", "<path>", "--no-pager", "-c", "core.quotepath=false", "-c", "color.ui=false",// "status", "--porcelain=v2", "--branch", "-z"]

Metadata-Only Repositories

A GitRepository produced by a hosting provider (rather than IGitClient.OpenAsync or DiscoverAsync) carries hosting metadata but no ProcessRunner. Calling any verb on it throws InvalidOperationException immediately, rather than failing later inside git:

GitRepositorymetadataOnly=new(){LocalPath=somePath,Name="GitIntegration".As<GitRepositoryName>()};// Throws InvalidOperationException — obtain a runnable repository from IGitClient first._=metadataOnly.Status();

API Reference

IGitClient / GitClient

The entry point to the local layer: finds and opens repositories, and reports on the git binary.

Methods

NameReturn TypeDescription
GetVersionAsync(CancellationToken)Task<GitVersion>Reports the version of the git binary being invoked.
IsRepositoryAsync(AbsoluteDirectoryPath, CancellationToken)Task<bool>Decides whether a path is inside a git working tree. Never throws for a non-repository path.
OpenAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository>Opens the repository containing a path. Throws GitRepositoryNotFoundException when there is none.
DiscoverAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository?>Opens the repository containing a path, returning null instead of throwing when there is none.
Init(AbsoluteDirectoryPath)IGitInitBuilderCreates a repository at a path. Probes first, so the result's AlreadyExisted can tell a caller whether one was already there.
Clone(GitRepositoryRemotePath, AbsoluteDirectoryPath)IGitCloneBuilderClones a repository into a local working copy.
Clone(GitRepository)IGitCloneBuilderClones the repository a hosting provider described, using its RemotePath and intended LocalPath.

GitRepository

Carries LocalPath plus optional hosting metadata, and exposes one builder factory per verb.

Properties

NameTypeDescription
LocalPathAbsoluteDirectoryPathThe working tree's local filesystem path.
NameGitRepositoryName?The repository name, when known.
WebURIGitRepositoryWebURI?The browser-facing URI, when known.
RemotePathGitRepositoryRemotePath?The remote clone path, when known.
ProcessRunnerIGitProcessRunner?The runner this repository's verbs execute through; null on a metadata-only repository.

Methods

NameReturn TypeDescription
Status()IGitStatusBuilderBuilds git status --porcelain=v2 --branch -z.
Log()IGitLogBuilderBuilds git log -z with this library's pinned format.
Diff()IGitDiffBuilderBuilds git diff --name-status -z.
Branches()IGitBranchListBuilderBuilds git for-each-ref over the branch namespaces.
Remotes()IGitRemoteListBuilderBuilds git remote -v.
RevParse(GitRefName)IGitRevParseBuilderBuilds git rev-parse --verify for a revision.
Add()IGitAddBuilderBuilds git add.
Commit(GitCommitMessage)IGitCommitBuilderBuilds git commit, then reads the new commit back with git log -1.
CreateBranch(GitBranchName)IGitBranchCreateBuilderBuilds git branch <name> [<start-point>].
DeleteBranch(GitBranchName)IGitBranchDeleteBuilderBuilds git branch --delete <name>.
Checkout(GitRefName)IGitCheckoutBuilderBuilds git checkout.
AddRemote(GitRemoteName, GitRepositoryRemotePath)IGitRemoteAddBuilderBuilds git remote add <name> <url>.
RemoveRemote(GitRemoteName)IGitRemoteRemoveBuilderBuilds git remote remove <name>.
SetRemoteUrl(GitRemoteName, GitRepositoryRemotePath)IGitRemoteSetUrlBuilderBuilds git remote set-url <name> <url>.
Fetch()IGitFetchBuilderBuilds git fetch, with --porcelain where the installed git supports it.
Pull()IGitPullBuilderBuilds git pull.
Push()IGitPushBuilderBuilds git push --porcelain.
IsClonedAsync(CancellationToken)Task<bool>Decides whether LocalPath currently holds a git working tree.
OpenWebClient()voidOpens WebURI in the default browser, when it is an absolute http/https URI.

IGitCommandBuilder<TResult>

The shared contract every verb builder implements. A builder is single-use and not thread-safe.

Methods

NameReturn TypeDescription
BuildArguments()IReadOnlyList<string>The exact argument vector this builder will pass to git. Pure, no I/O.
ExecuteAsync(CancellationToken)Task<TResult>Runs the command, throwing GitCommandException when git exits non-zero.
TryExecuteAsync(CancellationToken)Task<GitResult<TResult>>Runs the command, reporting a non-zero exit as a result instead of throwing.

Verb Builders

InterfaceExtra MethodsResult
IGitStatusBuilderWithUntrackedFiles(GitUntrackedFilesMode), IncludeIgnored()GitStatus
IGitLogBuilderTake(int), Skip(int), ForRevision(GitRefName), ForPath(RelativeFilePath), FirstParentOnly()IReadOnlyList<GitCommit>
IGitDiffBuilderStaged(), Against(GitRefName), Between(GitRefName, GitRefName), DetectRenames(), DetectCopies(), ForPath(RelativeFilePath)IReadOnlyList<GitDiffEntry>
IGitBranchListBuilderLocalOnly(), RemoteOnly()IReadOnlyList<GitBranch>
IGitRemoteListBuilder(none)IReadOnlyList<GitRemote>
IGitRevParseBuilder(none — revision supplied via GitRepository.RevParse)GitCommitSha
IGitInitBuilderBare(), WithInitialBranch(GitBranchName)GitInitResult
IGitCloneBuilderWithBranch(GitBranchName), WithDepth(int), Bare(), ReportingProgress(IProgress<string>)GitRepository
IGitAddBuilderForPath(RelativeFilePath), All(), UpdateTrackedOnly()GitCompleted
IGitCommitBuilderWithBody(string), AllowEmpty(), StageTrackedFiles(), WithAuthor(GitAuthorName, GitAuthorEmail)GitCommit
IGitBranchCreateBuilderStartingAt(GitRefName), Force()GitCompleted
IGitBranchDeleteBuilderForce()GitCompleted
IGitCheckoutBuilderCreatingBranch(), Force(), Detach()GitCompleted
IGitRemoteAddBuilderWithFetch()GitCompleted
IGitRemoteRemoveBuilder(none)GitCompleted
IGitRemoteSetUrlBuilderForPushOnly()GitCompleted
IGitFetchBuilderFromRemote(GitRemoteName), AllRemotes(), Prune(), WithTags(), WithDepth(int), ReportingProgress(IProgress<string>)GitFetchResult
IGitPullBuilderFromRemote(GitRemoteName), WithBranch(GitBranchName), FastForwardOnly(), Rebase(), Prune(), ReportingProgress(IProgress<string>)GitCompleted
IGitPushBuilderToRemote(GitRemoteName), WithBranch(GitBranchName), SettingUpstream(), Force(), ForceWithLease(), DeletingRemoteBranch(), DryRun(), ReportingProgress(IProgress<string>)GitPushResult

Result and Execution Models

TypeDescription
GitOptionsConfigures the git executable path and a per-invocation timeout.
IGitProcessRunnerRuns the git executable with a given argument vector; implemented by RunCommandGitProcessRunner.
GitResult<T>The outcome of a command run with TryExecuteAsync: either Value or Error, never both.
GitCommandErrorThe exit code, argument vector, and standard error of a failed invocation.

Exceptions

TypeThrown When
GitExceptionBase type for every failure originating in this library.
GitExecutableNotFoundExceptionThe git executable could not be started.
GitTimeoutExceptionGit did not complete within the configured GitOptions.Timeout.
GitParseExceptionGit succeeded but produced output the parser could not interpret.
GitCommandExceptionGit ran and exited non-zero. Carries ExitCode, Arguments, and StandardError.
GitRepositoryNotFoundExceptionA GitCommandException specialization: the path is not inside a git working tree.
GitNothingToCommitExceptionA GitCommandException specialization: git commit was run with nothing staged. The one commit failure that is an ordinary program state rather than a fault.
GitPushRejectedExceptionA GitCommandException specialization: git refused at least one reference during push. Carries the parsed Result (GitPushResult) so the rejection detail is not lost.
GitPullConflictExceptionA GitCommandException specialization: pull left conflicts in the working tree. Use Status() to see which paths are unmerged.
GitHostingExceptionBase type for every hosting-layer failure. Does not derive from GitException — it carries HTTP concepts, not process ones. Carries ProviderName, StatusCode, ResponseBody.
GitHostingAuthenticationExceptionA GitHostingException specialization: the host rejected the request as unauthenticated, or the credential no longer grants access.
GitHostingNotFoundExceptionA GitHostingException specialization: the requested resource does not exist, or is not visible to the caller's credentials.
GitHostingRateLimitExceptionA GitHostingException specialization: the caller has exhausted its request quota. Carries ResetsAt, when the host reported one.
GitHostingRequestExceptionA GitHostingException specialization for any other non-success response — a malformed request, a validation failure, or a server-side error.

Result Models

TypeDescription
GitStatusBranch, Upstream, Ahead, Behind, IsDetached, Entries, IsClean.
GitStatusEntryIndexState, WorkTreeState, Path, OriginalPath for one changed path.
GitCommitSha, TreeSha, ParentShas, Author, Committer, Subject, Body.
GitSignatureName, Email, Timestamp recorded on a commit.
GitBranchName, Sha, Upstream, IsCurrent, IsRemote.
GitRemoteName, FetchUrl, PushUrl.
GitDiffEntryKind, Path, OriginalPath, SimilarityPercent.
GitVersionMajor, Minor, Patch, Raw, plus AtLeast(major, minor).
GitFileStateEnum: Unmodified, Modified, Added, Deleted, Renamed, Copied, Untracked, Ignored, Unmerged, TypeChanged.
GitChangeKindEnum: Added, Copied, Deleted, Modified, Renamed, TypeChanged, Unmerged, Unknown.
GitUntrackedFilesModeEnum: No, Normal, All.
GitCompletedThe result of a mutating verb whose only outcome is success — add, checkout, branch creation/deletion, remote commands, and pull. Carries Arguments.
GitInitResultRepository, AlreadyExisted — the outcome of IGitClient.Init.
GitFetchResultUpdates, DetailAvailable, IsUpToDate — the outcome of Fetch(). IsUpToDate is gated on DetailAvailable so an empty Updates from a pre-2.41 git is never mistaken for "nothing changed".
GitPushResultUpdates, HasRejections — the outcome of Push().
GitRefUpdateKind, Reference, Source, OldSha, NewSha, Summary, IsRejected — one reference changed by a fetch or a push.
GitRefUpdateKindEnum: FastForward, Forced, Removed, Created, Rejected, UpToDate, TagUpdate, Unknown.
GitPullRequestNumber, Title, Description, SourceBranch, TargetBranch, Author, State, IsDraft, WebURI, CreatedAt — one pull request, as reported by a hosting provider. A null optional field means the host did not report that value.
GitPullRequestStateEnum: Open, Merged, Closed.

IGitHostingProvider

The contract every hosting provider implements: repository enumeration, pull request listing, and pull request creation, over whichever transport and authentication scheme the host requires.

Properties

NameTypeDescription
NameGitProviderNameDisplay name of the provider.
OwnerGitProviderOwnerThe owner of the repositories in this provider.
PersonaGUIDPersonaGUIDThe persona GUID used for authentication with the provider (from ktsu.CredentialCache).
IsAuthenticatedboolWhether requests this provider issues carry a credential. A cache entry that resolves to "proceed unauthenticated", and one of a type the provider cannot apply, both report false.

Methods

NameReturn TypeDescription
GetRepositoriesAsync(CancellationToken)Task<IReadOnlyList<GitRepository>>Retrieves the repositories Owner has, from the host. Coverage differs by host — see GitHubProvider and AzureDevOpsProvider below.
GetPullRequestsAsync(GitRepositoryName, CancellationToken)Task<IReadOnlyList<GitPullRequest>>Retrieves a repository's open pull requests. The filter is requested explicitly of the host, not left to its default.
CreatePullRequest(GitRepositoryName)IGitPullRequestCreateBuilderStarts building a pull request for a repository.

GitProvider

The abstract base both hosting providers derive from. Implements IGitHostingProvider and resolves credentials via TryGetCredential/ResolveCredential; each concrete provider builds its own HttpClient-based transport around that credential — see GitHubProvider and AzureDevOpsProvider below for how the two differ. public bool TryGetCredential(out Credential?) is declared here, not on IGitHostingProvider, and reports only whether the credential cache holds an entry for this provider's PersonaGUID. IsAuthenticated is the narrower question of whether a request would actually carry one.

Each provider type keeps one shared SocketsHttpHandler for the life of the process and builds a short-lived client or adapter over it per call, so a caller looping over many repositories reuses one connection pool rather than building and tearing down one per request. Neither provider is IDisposable, and neither ever disposes a handler injected for testing.

GitHubProvider

GitProvider implementation backed by Octokit. GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones, and supplying a token does not widen this.

AzureDevOpsProvider

GitProvider implementation built on a raw HttpClient against Azure DevOps's REST API (api-version=7.1) — no Azure DevOps client library is referenced (see the Introduction). GetPullRequestsAsync pages with $top and $skip until a short page comes back, so a repository with more open pull requests than one page costs more than one request and is never truncated. GetRepositoriesAsync issues exactly one request, because that endpoint documents no pagination.

Additional Properties

NameTypeDescription
ProjectAzureDevOpsProjectName?Scopes repository enumeration to a project, or null to enumerate the whole organisation. Required for GetPullRequestsAsync and CreatePullRequest — Azure DevOps has no project-less pull request endpoint, and calling either without it throws InvalidOperationException.

GetRepositoriesAsync returns everything the resolved credential can see — unlike GitHubProvider, there is no public-only restriction.

IGitPullRequestCreateBuilder

Collects a pull request's details before submitting it. From, Into, and Titled are required; Describing and AsDraft are optional. A missing required value throws InvalidOperationException from ExecuteAsync, not from the setter that left it unset, since parts may be supplied in any order.

NameReturn TypeDescription
From(GitBranchName)IGitPullRequestCreateBuilderSets the source branch.
Into(GitBranchName)IGitPullRequestCreateBuilderSets the target branch.
Titled(GitPullRequestTitle)IGitPullRequestCreateBuilderSets the title.
Describing(string)IGitPullRequestCreateBuilderSets the description.
AsDraft()IGitPullRequestCreateBuilderMarks the pull request as a draft.
ExecuteAsync(CancellationToken)Task<GitPullRequest>Submits the pull request to the host.

ServiceCollectionExtensions

NameReturn TypeDescription
AddGitIntegration(IServiceCollection)IServiceCollectionRegisters git integration with default options, invoking the git found on PATH.
AddGitIntegration(IServiceCollection, Action<GitOptions>)IServiceCollectionRegisters git integration with configured options. Idempotent per service.

Registers only the local layer. Hosting providers are constructed directly — see Working with a Hosting Provider — since neither GitHubProvider nor AzureDevOpsProvider has a constructor dependency a container could supply.

Semantic Types

TypeWraps
GitAuthorEmailCommit author or committer email address
GitAuthorNameCommit author or committer name
GitBranchNameBranch name
GitCommitMessageCommit message
GitCommitShaCommit object id (abbreviated or full, including SHA-256 repositories)
GitProviderNameHosting provider display name
GitProviderOwnerAccount or organization owning a repository
GitRefNameA branch, tag, SHA, or revision expression
GitRemoteNameRemote name
GitRepositoryNameRepository name
GitRepositoryRemotePathClone path or URL
GitRepositoryWebURIRepository web address
AzureDevOpsProjectNameAzure DevOps project name — scopes AzureDevOpsProvider repository enumeration, and required for its pull request operations
GitPullRequestNumberPull request's host-assigned number
GitPullRequestTitlePull request title
GitPullRequestAuthorHost's identifier for the account that opened a pull request (a GitHub login, or an Azure DevOps unique name)
GitPullRequestWebURIPull request web address

Contributing

Contributions are welcome! Feel free to open issues or submit pull requests.

License

This project is licensed under the MIT License. See the LICENSE.md file for details.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ktsu.GitIntegration

A .NET library that wraps the git binary behind a fluent, strongly-typed interface, and unifies access to hosted Git providers behind a single abstraction.

LicenseNuGet VersionNuGet VersionNuGet DownloadsGitHub commit activityGitHub contributorsGitHub Actions Workflow Status

Introduction

ktsu.GitIntegration is a two-layer library. The local layer wraps the git executable found on PATH behind a fluent, strongly-typed interface: open or discover a repository, then build and run both read-only commands (status, log, diff, branches, remotes, rev-parse) and mutating commands (init, clone, add, commit, branch creation and deletion, checkout, remote management, and remote sync via fetch, pull, and push) without shelling out or hand-parsing porcelain output yourself. The hosting layer — the original half of this library — unifies access to hosted Git providers behind a GitProvider abstraction: GitHubProvider, built on Octokit, and AzureDevOpsProvider, built on a raw HttpClient against Azure DevOps's REST API. Both enumerate repositories and list and create pull requests through the same IGitHostingProvider contract.

Every value that would otherwise be a bare string — a branch name, a commit SHA, a remote name, an author email — is instead a validated semantic type built on ktsu.Semantics, so a GitBranchName can no longer be accidentally passed where a GitCommitSha is expected.

Azure DevOps hosting deliberately does not use Microsoft.TeamFoundationServer.Client or the other official TFS/Azure DevOps client package — both pull in System.Data.SqlClient, which carries a known high-severity advisory, as a direct dependency of the published package. AzureDevOpsProvider builds and parses its requests by hand instead.

Features

  • Local Git Client: IGitClient/GitClient finds and opens repositories — GetVersionAsync, IsRepositoryAsync, OpenAsync, DiscoverAsync — and creates new ones — Init(...), Clone(...) — by delegating every invocation to ktsu.RunCommand.
  • Fluent Verb Builders: GitRepository exposes one builder per read-only verb — Status(), Log(), Diff(), Branches(), Remotes(), RevParse(...) — and one per mutating verb — Add(), Commit(...), CreateBranch(...), DeleteBranch(...), Checkout(...), AddRemote(...), RemoveRemote(...), SetRemoteUrl(...), Fetch(), Pull(), Push() — each configurable via chained method calls and run with ExecuteAsync or the non-throwing TryExecuteAsync.
  • Remote Sync: Fetch() downloads objects and refs without touching the working tree, Pull() fetches and integrates into the current branch, and Push() sends local commits — fetch and push report a machine-readable, per-reference account of what happened via GitFetchResult/GitPushResult, and a rejected push is the one place in this library where ExecuteAsync and TryExecuteAsync diverge in more than exception-versus-result.
  • Strongly-Typed Results: GitStatus, GitCommit, GitBranch, GitRemote, GitDiffEntry, GitVersion, GitInitResult, GitCompleted, GitFetchResult, GitPushResult, and GitRefUpdate records replace ad-hoc porcelain parsing with typed models — GitCompleted is the shared result for mutating verbs whose only outcome is success.
  • Reproducible Failures: every command is scoped with git -C <path> instead of a process working directory, so a failing invocation's exact argument vector can be read off a GitCommandException and rerun verbatim.
  • Locale-Safe Parsing: every invocation runs with GIT_TERMINAL_PROMPT=0 (no hanging credential prompts) and LC_ALL=C (English, machine-stable output), which is what makes the output parsers safe to write against fixed English text.
  • Dependency Injection: AddGitIntegration() registers the client, process runner, and options as singletons in one call. The hosting layer is constructed directly instead — see Working with a Hosting Provider.
  • Hosting Provider Abstraction: IGitHostingProvider defines a common contract for enumerating repositories, listing open pull requests, and creating a pull request — GitHubProvider implements it on top of Octokit, AzureDevOpsProvider on a raw HttpClient against Azure DevOps's REST API.
  • Credential Resolution: hosting providers integrate with ktsu.CredentialCache, so credentials come from the host's native keyring rather than configuration files.
  • Semantic Git Types: validated wrapper types for every identifier Git tooling passes around, so mismatched arguments fail at compile time rather than at runtime.

Installation

Package Manager Console

Install-Package ktsu.GitIntegration

.NET CLI

dotnet add package ktsu.GitIntegration

Package Reference

<PackageReferenceInclude="ktsu.GitIntegration"Version="x.y.z" />

Usage Examples

Basic Example

Register the library with dependency injection, then resolve IGitClient:

usingktsu.GitIntegration;usingMicrosoft.Extensions.DependencyInjection;ServiceCollectionservices=new();services.AddGitIntegration();usingServiceProviderprovider=services.BuildServiceProvider();IGitClientclient=provider.GetRequiredService<IGitClient>();

Discovering a Repository and Reading Its Status

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathhere=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepository?repository=awaitclient.DiscoverAsync(here);if(repositoryis not null){GitStatusstatus=awaitrepository.Status().ExecuteAsync();Console.WriteLine(status.IsClean?"Working tree is clean.":$"{status.Entries.Count} changed path(s) on {status.Branch?.WeakString}.");}

Listing Commits and Diffs

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IReadOnlyList<GitCommit>commits=awaitrepository.Log().Take(10).FirstParentOnly().ExecuteAsync();foreach(GitCommitcommitincommits){Console.WriteLine($"{commit.Sha.WeakString[..7]}{commit.Subject}");}IReadOnlyList<GitDiffEntry>changes=awaitrepository.Diff().Staged().DetectRenames().ExecuteAsync();

Initializing or Cloning a Repository

Init probes the target path before running git init, so GitInitResult.AlreadyExisted can tell a caller whether a repository was already there — git init is idempotent and silently ignores --initial-branch when re-initialising, so a caller that asked for a particular initial branch and got AlreadyExisted == true did not get the branch it asked for:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathtarget=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitInitResultinit=awaitclient.Init(target).WithInitialBranch("main".As<GitBranchName>()).ExecuteAsync();GitRepositoryrepository=init.Repository;

Clone builds git clone. Its destination pre-check is advisory only — git enforces the same rule itself, so the check exists solely to fail a doomed clone before it pays its network cost, and it is deliberately racy:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;GitRepositoryRemotePathsource="https://github.com/ktsu-dev/GitIntegration.git".As<GitRepositoryRemotePath>();AbsoluteDirectoryPathdestination=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepositorycloned=awaitclient.Clone(source,destination).WithDepth(1).ReportingProgress(newProgress<string>(line =>Console.WriteLine(line))).ExecuteAsync();

Staging and Committing Changes

Commit runs git twice: git commit itself, then git log -1 with this library's pinned format, because commit's own output is a human summary carrying only an abbreviated object id, with no machine-readable alternative:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.Add().All().ExecuteAsync();GitCommitcommit=awaitrepository.Commit("Add feature X".As<GitCommitMessage>()).WithBody("Longer explanation of the change.").WithAuthor("Ada Lovelace".As<GitAuthorName>(),"ada@example.com".As<GitAuthorEmail>()).ExecuteAsync();Console.WriteLine(commit.Sha.WeakString);

Committing with nothing staged throws GitNothingToCommitException, a GitCommandException specialization, rather than the generic base type — the one commit failure that is an ordinary program state rather than a fault:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Commit("Nothing changed".As<GitCommitMessage>()).ExecuteAsync();}catch(GitNothingToCommitException){Console.WriteLine("Nothing was staged; skipping this commit.");}

Creating Branches and Switching

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.CreateBranch("feature/new-thing".As<GitBranchName>()).StartingAt("main".As<GitRefName>()).ExecuteAsync();awaitrepository.Checkout("feature/new-thing".As<GitRefName>()).ExecuteAsync();// Later, once the branch is no longer needed:awaitrepository.DeleteBranch("feature/new-thing".As<GitBranchName>()).Force().ExecuteAsync();

Managing Remotes

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryRemotePathurl="https://github.com/example/repo.git".As<GitRepositoryRemotePath>();awaitrepository.AddRemote("upstream".As<GitRemoteName>(),url).WithFetch().ExecuteAsync();awaitrepository.SetRemoteUrl("upstream".As<GitRemoteName>(),url).ForPushOnly().ExecuteAsync();awaitrepository.RemoveRemote("upstream".As<GitRemoteName>()).ExecuteAsync();

Fetching

fetch --porcelain is only available from git 2.41 onward, so Fetch() probes the installed git's version first. Below that threshold the fetch still runs and still succeeds, but GitFetchResult.DetailAvailable is false and Updates is empty — check DetailAvailable before trusting an empty Updates as "nothing changed":

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitFetchResultfetched=awaitrepository.Fetch().FromRemote("origin".As<GitRemoteName>()).Prune().WithTags().ExecuteAsync();if(fetched.DetailAvailable){Console.WriteLine(fetched.IsUpToDate?"Already up to date.":$"{fetched.Updates.Count} reference(s) updated.");}else{Console.WriteLine("Fetch completed, but this git is older than 2.41 so no per-reference detail is available.");}

Pulling

Pull() returns GitCompleted rather than a parsed result, because everything git pull prints is human prose with no porcelain form. Use Status() and Log() afterwards to learn what changed. A merge conflict is the one outcome with its own exception, GitPullConflictException, because it leaves the repository mid-merge:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Pull().FromRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).FastForwardOnly().ExecuteAsync();}catch(GitPullConflictException){GitStatusstatus=awaitrepository.Status().ExecuteAsync();IEnumerable<GitStatusEntry>unmerged=status.Entries.Where(e =>e.IndexState==GitFileState.Unmerged);Console.WriteLine($"Pull left {unmerged.Count()} unmerged path(s); resolve them and commit.");}

FastForwardOnly() and Rebase() are mutually exclusive — combining them throws InvalidOperationException when the argument vector is built, since they mean opposite things about history.

Pushing — Why ExecuteAsync and TryExecuteAsync Disagree

push is the one verb in this library where the two entry points mean genuinely different things, not just exception-versus-result. A rejected push exits non-zero from git and prints a complete porcelain record of every reference — git got far enough to talk about them and refused some. A caller who does not know this will get it wrong by assuming TryExecuteAsync returning Success means the push landed:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;// ExecuteAsync stays strict: a rejection throws, and the exception carries the full parsed result.try{GitPushResultpushed=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).SettingUpstream().ExecuteAsync();}catch(GitPushRejectedExceptionex){// ex.Result is the same GitPushResult a successful push would have returned.foreach(GitRefUpdateupdateinex.Result!.Updates.Where(u =>u.IsRejected)){Console.WriteLine($"{update.Reference.WeakString}: {update.Summary}");}}

TryExecuteAsync does not throw for a rejection — git ran and reported exactly what happened, so that report comes back as a successful GitResult. Always check HasRejections, not just Success:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitPushResult>result=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).TryExecuteAsync();if(result.Success&&result.Value!.HasRejections){// This is still result.Success == true: TryExecuteAsync only fails when git never reached// the remote at all. A rejection is reported as data, not as GitResult failure.Console.WriteLine("Push ran but at least one reference was rejected — check result.Value.Updates.");}

ForceWithLease() wins over Force() when both are set, being the safer of the two — it refuses if the remote moved since it was last fetched.

Resolving a Revision Without Throwing

TryExecuteAsync reports a non-zero exit as a result instead of an exception — useful when "no such revision" is an expected outcome rather than a failure:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitCommitSha>result=awaitrepository.RevParse("maybe-missing-branch".As<GitRefName>()).TryExecuteAsync();if(result.Success){Console.WriteLine(result.Value!.WeakString);}else{Console.WriteLine($"git exited {result.Error!.ExitCode}: {result.Error.StandardError}");}

Reproducing a Failing Command

ExecuteAsync throws GitCommandException on a non-zero exit, carrying the exact argument vector git was invoked with:

try{awaitrepository.RevParse("no-such-ref".As<GitRefName>()).ExecuteAsync();}catch(GitCommandExceptionex){// ex.Arguments already begins with "-C <path>", so this can be pasted straight after `git`// on a command line to reproduce the failure exactly.Console.WriteLine("git "+string.Join(' ',ex.Arguments));}

Working with a Hosting Provider

Providers are constructed directly rather than resolved from DI: GitHubProvider and AzureDevOpsProvider need only a caller-supplied Owner (and, for Azure DevOps, an optional Project), so there is nothing a container would meaningfully wire up.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProvidergithub=newGitHubProvider{Owner="ktsu-dev".As<GitProviderOwner>(),};// Credentials come from ktsu.CredentialCache, keyed by PersonaGUID — nothing to configure here// unless a specific persona is needed.IReadOnlyList<GitRepository>repositories=awaitgithub.GetRepositoriesAsync();

GitHubProvider.GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones. Azure DevOps has no equivalent restriction: it returns everything the resolved credential can see.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProviderazure=newAzureDevOpsProvider{Owner="my-org".As<GitProviderOwner>(),Project="my-project".As<AzureDevOpsProjectName>(),};IReadOnlyList<GitRepository>repositories=awaitazure.GetRepositoriesAsync();

Project is only required for pull request operations — Azure DevOps has no project-less pull request endpoint, and calling GetPullRequestsAsync or CreatePullRequest without it throws InvalidOperationException immediately. GetPullRequestsAsync returns open pull requests only, on both hosts:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryNamerepositoryName="GitIntegration".As<GitRepositoryName>();IReadOnlyList<GitPullRequest>openPullRequests=awaitazure.GetPullRequestsAsync(repositoryName);foreach(GitPullRequestpullRequestinopenPullRequests){Console.WriteLine($"#{pullRequest.Number.WeakString}{pullRequest.Title.WeakString} ({pullRequest.State})");}

Creating a pull request goes through a builder, the same idiom as the local layer's mutating verbs:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitPullRequestcreated=awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).Describing("Longer explanation of the change.").ExecuteAsync();Console.WriteLine(created.WebURI?.WeakString);

A hosting failure — authentication, not found, rate limiting, or anything else the host reports — surfaces as a GitHostingException subtype carrying the provider name, HTTP status, and response body, mirroring how GitCommandException carries the argument vector for the local layer:

try{awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).ExecuteAsync();}catch(GitHostingAuthenticationExceptionex){Console.WriteLine($"{ex.ProviderName} rejected the request: {ex.StatusCode}");}catch(GitHostingRateLimitExceptionex){Console.WriteLine($"Rate limited until {ex.ResetsAt}.");}

Working with Semantic Types

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitBranchNamebranch="main".As<GitBranchName>();GitRemoteNameremote="origin".As<GitRemoteName>();GitCommitShasha="9fceb02d0ae598e95dc970b74767f19372d61af8".As<GitCommitSha>();// These are distinct types — passing a GitBranchName where a GitCommitSha// is expected is a compile error, not a runtime surprise.

Advanced Usage

Argument Vectors Are Inspectable Before They Run

Every builder's BuildArguments() is a pure computation with no I/O, so the exact command can be asserted or logged before it executes:

IReadOnlyList<string>arguments=repository.Status().BuildArguments();// ["-C", "<path>", "--no-pager", "-c", "core.quotepath=false", "-c", "color.ui=false",// "status", "--porcelain=v2", "--branch", "-z"]

Metadata-Only Repositories

A GitRepository produced by a hosting provider (rather than IGitClient.OpenAsync or DiscoverAsync) carries hosting metadata but no ProcessRunner. Calling any verb on it throws InvalidOperationException immediately, rather than failing later inside git:

GitRepositorymetadataOnly=new(){LocalPath=somePath,Name="GitIntegration".As<GitRepositoryName>()};// Throws InvalidOperationException — obtain a runnable repository from IGitClient first._=metadataOnly.Status();

API Reference

IGitClient / GitClient

The entry point to the local layer: finds and opens repositories, and reports on the git binary.

Methods

NameReturn TypeDescription
GetVersionAsync(CancellationToken)Task<GitVersion>Reports the version of the git binary being invoked.
IsRepositoryAsync(AbsoluteDirectoryPath, CancellationToken)Task<bool>Decides whether a path is inside a git working tree. Never throws for a non-repository path.
OpenAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository>Opens the repository containing a path. Throws GitRepositoryNotFoundException when there is none.
DiscoverAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository?>Opens the repository containing a path, returning null instead of throwing when there is none.
Init(AbsoluteDirectoryPath)IGitInitBuilderCreates a repository at a path. Probes first, so the result's AlreadyExisted can tell a caller whether one was already there.
Clone(GitRepositoryRemotePath, AbsoluteDirectoryPath)IGitCloneBuilderClones a repository into a local working copy.
Clone(GitRepository)IGitCloneBuilderClones the repository a hosting provider described, using its RemotePath and intended LocalPath.

GitRepository

Carries LocalPath plus optional hosting metadata, and exposes one builder factory per verb.

Properties

NameTypeDescription
LocalPathAbsoluteDirectoryPathThe working tree's local filesystem path.
NameGitRepositoryName?The repository name, when known.
WebURIGitRepositoryWebURI?The browser-facing URI, when known.
RemotePathGitRepositoryRemotePath?The remote clone path, when known.
ProcessRunnerIGitProcessRunner?The runner this repository's verbs execute through; null on a metadata-only repository.

Methods

NameReturn TypeDescription
Status()IGitStatusBuilderBuilds git status --porcelain=v2 --branch -z.
Log()IGitLogBuilderBuilds git log -z with this library's pinned format.
Diff()IGitDiffBuilderBuilds git diff --name-status -z.
Branches()IGitBranchListBuilderBuilds git for-each-ref over the branch namespaces.
Remotes()IGitRemoteListBuilderBuilds git remote -v.
RevParse(GitRefName)IGitRevParseBuilderBuilds git rev-parse --verify for a revision.
Add()IGitAddBuilderBuilds git add.
Commit(GitCommitMessage)IGitCommitBuilderBuilds git commit, then reads the new commit back with git log -1.
CreateBranch(GitBranchName)IGitBranchCreateBuilderBuilds git branch <name> [<start-point>].
DeleteBranch(GitBranchName)IGitBranchDeleteBuilderBuilds git branch --delete <name>.
Checkout(GitRefName)IGitCheckoutBuilderBuilds git checkout.
AddRemote(GitRemoteName, GitRepositoryRemotePath)IGitRemoteAddBuilderBuilds git remote add <name> <url>.
RemoveRemote(GitRemoteName)IGitRemoteRemoveBuilderBuilds git remote remove <name>.
SetRemoteUrl(GitRemoteName, GitRepositoryRemotePath)IGitRemoteSetUrlBuilderBuilds git remote set-url <name> <url>.
Fetch()IGitFetchBuilderBuilds git fetch, with --porcelain where the installed git supports it.
Pull()IGitPullBuilderBuilds git pull.
Push()IGitPushBuilderBuilds git push --porcelain.
IsClonedAsync(CancellationToken)Task<bool>Decides whether LocalPath currently holds a git working tree.
OpenWebClient()voidOpens WebURI in the default browser, when it is an absolute http/https URI.

IGitCommandBuilder<TResult>

The shared contract every verb builder implements. A builder is single-use and not thread-safe.

Methods

NameReturn TypeDescription
BuildArguments()IReadOnlyList<string>The exact argument vector this builder will pass to git. Pure, no I/O.
ExecuteAsync(CancellationToken)Task<TResult>Runs the command, throwing GitCommandException when git exits non-zero.
TryExecuteAsync(CancellationToken)Task<GitResult<TResult>>Runs the command, reporting a non-zero exit as a result instead of throwing.

Verb Builders

InterfaceExtra MethodsResult
IGitStatusBuilderWithUntrackedFiles(GitUntrackedFilesMode), IncludeIgnored()GitStatus
IGitLogBuilderTake(int), Skip(int), ForRevision(GitRefName), ForPath(RelativeFilePath), FirstParentOnly()IReadOnlyList<GitCommit>
IGitDiffBuilderStaged(), Against(GitRefName), Between(GitRefName, GitRefName), DetectRenames(), DetectCopies(), ForPath(RelativeFilePath)IReadOnlyList<GitDiffEntry>
IGitBranchListBuilderLocalOnly(), RemoteOnly()IReadOnlyList<GitBranch>
IGitRemoteListBuilder(none)IReadOnlyList<GitRemote>
IGitRevParseBuilder(none — revision supplied via GitRepository.RevParse)GitCommitSha
IGitInitBuilderBare(), WithInitialBranch(GitBranchName)GitInitResult
IGitCloneBuilderWithBranch(GitBranchName), WithDepth(int), Bare(), ReportingProgress(IProgress<string>)GitRepository
IGitAddBuilderForPath(RelativeFilePath), All(), UpdateTrackedOnly()GitCompleted
IGitCommitBuilderWithBody(string), AllowEmpty(), StageTrackedFiles(), WithAuthor(GitAuthorName, GitAuthorEmail)GitCommit
IGitBranchCreateBuilderStartingAt(GitRefName), Force()GitCompleted
IGitBranchDeleteBuilderForce()GitCompleted
IGitCheckoutBuilderCreatingBranch(), Force(), Detach()GitCompleted
IGitRemoteAddBuilderWithFetch()GitCompleted
IGitRemoteRemoveBuilder(none)GitCompleted
IGitRemoteSetUrlBuilderForPushOnly()GitCompleted
IGitFetchBuilderFromRemote(GitRemoteName), AllRemotes(), Prune(), WithTags(), WithDepth(int), ReportingProgress(IProgress<string>)GitFetchResult
IGitPullBuilderFromRemote(GitRemoteName), WithBranch(GitBranchName), FastForwardOnly(), Rebase(), Prune(), ReportingProgress(IProgress<string>)GitCompleted
IGitPushBuilderToRemote(GitRemoteName), WithBranch(GitBranchName), SettingUpstream(), Force(), ForceWithLease(), DeletingRemoteBranch(), DryRun(), ReportingProgress(IProgress<string>)GitPushResult

Result and Execution Models

TypeDescription
GitOptionsConfigures the git executable path and a per-invocation timeout.
IGitProcessRunnerRuns the git executable with a given argument vector; implemented by RunCommandGitProcessRunner.
GitResult<T>The outcome of a command run with TryExecuteAsync: either Value or Error, never both.
GitCommandErrorThe exit code, argument vector, and standard error of a failed invocation.

Exceptions

TypeThrown When
GitExceptionBase type for every failure originating in this library.
GitExecutableNotFoundExceptionThe git executable could not be started.
GitTimeoutExceptionGit did not complete within the configured GitOptions.Timeout.
GitParseExceptionGit succeeded but produced output the parser could not interpret.
GitCommandExceptionGit ran and exited non-zero. Carries ExitCode, Arguments, and StandardError.
GitRepositoryNotFoundExceptionA GitCommandException specialization: the path is not inside a git working tree.
GitNothingToCommitExceptionA GitCommandException specialization: git commit was run with nothing staged. The one commit failure that is an ordinary program state rather than a fault.
GitPushRejectedExceptionA GitCommandException specialization: git refused at least one reference during push. Carries the parsed Result (GitPushResult) so the rejection detail is not lost.
GitPullConflictExceptionA GitCommandException specialization: pull left conflicts in the working tree. Use Status() to see which paths are unmerged.
GitHostingExceptionBase type for every hosting-layer failure. Does not derive from GitException — it carries HTTP concepts, not process ones. Carries ProviderName, StatusCode, ResponseBody.
GitHostingAuthenticationExceptionA GitHostingException specialization: the host rejected the request as unauthenticated, or the credential no longer grants access.
GitHostingNotFoundExceptionA GitHostingException specialization: the requested resource does not exist, or is not visible to the caller's credentials.
GitHostingRateLimitExceptionA GitHostingException specialization: the caller has exhausted its request quota. Carries ResetsAt, when the host reported one.
GitHostingRequestExceptionA GitHostingException specialization for any other non-success response — a malformed request, a validation failure, or a server-side error.

Result Models

TypeDescription
GitStatusBranch, Upstream, Ahead, Behind, IsDetached, Entries, IsClean.
GitStatusEntryIndexState, WorkTreeState, Path, OriginalPath for one changed path.
GitCommitSha, TreeSha, ParentShas, Author, Committer, Subject, Body.
GitSignatureName, Email, Timestamp recorded on a commit.
GitBranchName, Sha, Upstream, IsCurrent, IsRemote.
GitRemoteName, FetchUrl, PushUrl.
GitDiffEntryKind, Path, OriginalPath, SimilarityPercent.
GitVersionMajor, Minor, Patch, Raw, plus AtLeast(major, minor).
GitFileStateEnum: Unmodified, Modified, Added, Deleted, Renamed, Copied, Untracked, Ignored, Unmerged, TypeChanged.
GitChangeKindEnum: Added, Copied, Deleted, Modified, Renamed, TypeChanged, Unmerged, Unknown.
GitUntrackedFilesModeEnum: No, Normal, All.
GitCompletedThe result of a mutating verb whose only outcome is success — add, checkout, branch creation/deletion, remote commands, and pull. Carries Arguments.
GitInitResultRepository, AlreadyExisted — the outcome of IGitClient.Init.
GitFetchResultUpdates, DetailAvailable, IsUpToDate — the outcome of Fetch(). IsUpToDate is gated on DetailAvailable so an empty Updates from a pre-2.41 git is never mistaken for "nothing changed".
GitPushResultUpdates, HasRejections — the outcome of Push().
GitRefUpdateKind, Reference, Source, OldSha, NewSha, Summary, IsRejected — one reference changed by a fetch or a push.
GitRefUpdateKindEnum: FastForward, Forced, Removed, Created, Rejected, UpToDate, TagUpdate, Unknown.
GitPullRequestNumber, Title, Description, SourceBranch, TargetBranch, Author, State, IsDraft, WebURI, CreatedAt — one pull request, as reported by a hosting provider. A null optional field means the host did not report that value.
GitPullRequestStateEnum: Open, Merged, Closed.

IGitHostingProvider

The contract every hosting provider implements: repository enumeration, pull request listing, and pull request creation, over whichever transport and authentication scheme the host requires.

Properties

NameTypeDescription
NameGitProviderNameDisplay name of the provider.
OwnerGitProviderOwnerThe owner of the repositories in this provider.
PersonaGUIDPersonaGUIDThe persona GUID used for authentication with the provider (from ktsu.CredentialCache).
IsAuthenticatedboolWhether requests this provider issues carry a credential. A cache entry that resolves to "proceed unauthenticated", and one of a type the provider cannot apply, both report false.

Methods

NameReturn TypeDescription
GetRepositoriesAsync(CancellationToken)Task<IReadOnlyList<GitRepository>>Retrieves the repositories Owner has, from the host. Coverage differs by host — see GitHubProvider and AzureDevOpsProvider below.
GetPullRequestsAsync(GitRepositoryName, CancellationToken)Task<IReadOnlyList<GitPullRequest>>Retrieves a repository's open pull requests. The filter is requested explicitly of the host, not left to its default.
CreatePullRequest(GitRepositoryName)IGitPullRequestCreateBuilderStarts building a pull request for a repository.

GitProvider

The abstract base both hosting providers derive from. Implements IGitHostingProvider and resolves credentials via TryGetCredential/ResolveCredential; each concrete provider builds its own HttpClient-based transport around that credential — see GitHubProvider and AzureDevOpsProvider below for how the two differ. public bool TryGetCredential(out Credential?) is declared here, not on IGitHostingProvider, and reports only whether the credential cache holds an entry for this provider's PersonaGUID. IsAuthenticated is the narrower question of whether a request would actually carry one.

Each provider type keeps one shared SocketsHttpHandler for the life of the process and builds a short-lived client or adapter over it per call, so a caller looping over many repositories reuses one connection pool rather than building and tearing down one per request. Neither provider is IDisposable, and neither ever disposes a handler injected for testing.

GitHubProvider

GitProvider implementation backed by Octokit. GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones, and supplying a token does not widen this.

AzureDevOpsProvider

GitProvider implementation built on a raw HttpClient against Azure DevOps's REST API (api-version=7.1) — no Azure DevOps client library is referenced (see the Introduction). GetPullRequestsAsync pages with $top and $skip until a short page comes back, so a repository with more open pull requests than one page costs more than one request and is never truncated. GetRepositoriesAsync issues exactly one request, because that endpoint documents no pagination.

Additional Properties

NameTypeDescription
ProjectAzureDevOpsProjectName?Scopes repository enumeration to a project, or null to enumerate the whole organisation. Required for GetPullRequestsAsync and CreatePullRequest — Azure DevOps has no project-less pull request endpoint, and calling either without it throws InvalidOperationException.

GetRepositoriesAsync returns everything the resolved credential can see — unlike GitHubProvider, there is no public-only restriction.

IGitPullRequestCreateBuilder

Collects a pull request's details before submitting it. From, Into, and Titled are required; Describing and AsDraft are optional. A missing required value throws InvalidOperationException from ExecuteAsync, not from the setter that left it unset, since parts may be supplied in any order.

NameReturn TypeDescription
From(GitBranchName)IGitPullRequestCreateBuilderSets the source branch.
Into(GitBranchName)IGitPullRequestCreateBuilderSets the target branch.
Titled(GitPullRequestTitle)IGitPullRequestCreateBuilderSets the title.
Describing(string)IGitPullRequestCreateBuilderSets the description.
AsDraft()IGitPullRequestCreateBuilderMarks the pull request as a draft.
ExecuteAsync(CancellationToken)Task<GitPullRequest>Submits the pull request to the host.

ServiceCollectionExtensions

NameReturn TypeDescription
AddGitIntegration(IServiceCollection)IServiceCollectionRegisters git integration with default options, invoking the git found on PATH.
AddGitIntegration(IServiceCollection, Action<GitOptions>)IServiceCollectionRegisters git integration with configured options. Idempotent per service.

Registers only the local layer. Hosting providers are constructed directly — see Working with a Hosting Provider — since neither GitHubProvider nor AzureDevOpsProvider has a constructor dependency a container could supply.

Semantic Types

TypeWraps
GitAuthorEmailCommit author or committer email address
GitAuthorNameCommit author or committer name
GitBranchNameBranch name
GitCommitMessageCommit message
GitCommitShaCommit object id (abbreviated or full, including SHA-256 repositories)
GitProviderNameHosting provider display name
GitProviderOwnerAccount or organization owning a repository
GitRefNameA branch, tag, SHA, or revision expression
GitRemoteNameRemote name
GitRepositoryNameRepository name
GitRepositoryRemotePathClone path or URL
GitRepositoryWebURIRepository web address
AzureDevOpsProjectNameAzure DevOps project name — scopes AzureDevOpsProvider repository enumeration, and required for its pull request operations
GitPullRequestNumberPull request's host-assigned number
GitPullRequestTitlePull request title
GitPullRequestAuthorHost's identifier for the account that opened a pull request (a GitHub login, or an Azure DevOps unique name)
GitPullRequestWebURIPull request web address

Contributing

Contributions are welcome! Feel free to open issues or submit pull requests.

License

This project is licensed under the MIT License. See the LICENSE.md file for details.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

ktsu.GitIntegration

A .NET library that wraps the git binary behind a fluent, strongly-typed interface, and unifies access to hosted Git providers behind a single abstraction.

LicenseNuGet VersionNuGet VersionNuGet DownloadsGitHub commit activityGitHub contributorsGitHub Actions Workflow Status

Introduction

ktsu.GitIntegration is a two-layer library. The local layer wraps the git executable found on PATH behind a fluent, strongly-typed interface: open or discover a repository, then build and run both read-only commands (status, log, diff, branches, remotes, rev-parse) and mutating commands (init, clone, add, commit, branch creation and deletion, checkout, remote management, and remote sync via fetch, pull, and push) without shelling out or hand-parsing porcelain output yourself. The hosting layer — the original half of this library — unifies access to hosted Git providers behind a GitProvider abstraction: GitHubProvider, built on Octokit, and AzureDevOpsProvider, built on a raw HttpClient against Azure DevOps's REST API. Both enumerate repositories and list and create pull requests through the same IGitHostingProvider contract.

Every value that would otherwise be a bare string — a branch name, a commit SHA, a remote name, an author email — is instead a validated semantic type built on ktsu.Semantics, so a GitBranchName can no longer be accidentally passed where a GitCommitSha is expected.

Azure DevOps hosting deliberately does not use Microsoft.TeamFoundationServer.Client or the other official TFS/Azure DevOps client package — both pull in System.Data.SqlClient, which carries a known high-severity advisory, as a direct dependency of the published package. AzureDevOpsProvider builds and parses its requests by hand instead.

Features

  • Local Git Client: IGitClient/GitClient finds and opens repositories — GetVersionAsync, IsRepositoryAsync, OpenAsync, DiscoverAsync — and creates new ones — Init(...), Clone(...) — by delegating every invocation to ktsu.RunCommand.
  • Fluent Verb Builders: GitRepository exposes one builder per read-only verb — Status(), Log(), Diff(), Branches(), Remotes(), RevParse(...) — and one per mutating verb — Add(), Commit(...), CreateBranch(...), DeleteBranch(...), Checkout(...), AddRemote(...), RemoveRemote(...), SetRemoteUrl(...), Fetch(), Pull(), Push() — each configurable via chained method calls and run with ExecuteAsync or the non-throwing TryExecuteAsync.
  • Remote Sync: Fetch() downloads objects and refs without touching the working tree, Pull() fetches and integrates into the current branch, and Push() sends local commits — fetch and push report a machine-readable, per-reference account of what happened via GitFetchResult/GitPushResult, and a rejected push is the one place in this library where ExecuteAsync and TryExecuteAsync diverge in more than exception-versus-result.
  • Strongly-Typed Results: GitStatus, GitCommit, GitBranch, GitRemote, GitDiffEntry, GitVersion, GitInitResult, GitCompleted, GitFetchResult, GitPushResult, and GitRefUpdate records replace ad-hoc porcelain parsing with typed models — GitCompleted is the shared result for mutating verbs whose only outcome is success.
  • Reproducible Failures: every command is scoped with git -C <path> instead of a process working directory, so a failing invocation's exact argument vector can be read off a GitCommandException and rerun verbatim.
  • Locale-Safe Parsing: every invocation runs with GIT_TERMINAL_PROMPT=0 (no hanging credential prompts) and LC_ALL=C (English, machine-stable output), which is what makes the output parsers safe to write against fixed English text.
  • Dependency Injection: AddGitIntegration() registers the client, process runner, and options as singletons in one call. The hosting layer is constructed directly instead — see Working with a Hosting Provider.
  • Hosting Provider Abstraction: IGitHostingProvider defines a common contract for enumerating repositories, listing open pull requests, and creating a pull request — GitHubProvider implements it on top of Octokit, AzureDevOpsProvider on a raw HttpClient against Azure DevOps's REST API.
  • Credential Resolution: hosting providers integrate with ktsu.CredentialCache, so credentials come from the host's native keyring rather than configuration files.
  • Semantic Git Types: validated wrapper types for every identifier Git tooling passes around, so mismatched arguments fail at compile time rather than at runtime.

Installation

Package Manager Console

Install-Package ktsu.GitIntegration

.NET CLI

dotnet add package ktsu.GitIntegration

Package Reference

<PackageReferenceInclude="ktsu.GitIntegration"Version="x.y.z" />

Usage Examples

Basic Example

Register the library with dependency injection, then resolve IGitClient:

usingktsu.GitIntegration;usingMicrosoft.Extensions.DependencyInjection;ServiceCollectionservices=new();services.AddGitIntegration();usingServiceProviderprovider=services.BuildServiceProvider();IGitClientclient=provider.GetRequiredService<IGitClient>();

Discovering a Repository and Reading Its Status

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathhere=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepository?repository=awaitclient.DiscoverAsync(here);if(repositoryis not null){GitStatusstatus=awaitrepository.Status().ExecuteAsync();Console.WriteLine(status.IsClean?"Working tree is clean.":$"{status.Entries.Count} changed path(s) on {status.Branch?.WeakString}.");}

Listing Commits and Diffs

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IReadOnlyList<GitCommit>commits=awaitrepository.Log().Take(10).FirstParentOnly().ExecuteAsync();foreach(GitCommitcommitincommits){Console.WriteLine($"{commit.Sha.WeakString[..7]}{commit.Subject}");}IReadOnlyList<GitDiffEntry>changes=awaitrepository.Diff().Staged().DetectRenames().ExecuteAsync();

Initializing or Cloning a Repository

Init probes the target path before running git init, so GitInitResult.AlreadyExisted can tell a caller whether a repository was already there — git init is idempotent and silently ignores --initial-branch when re-initialising, so a caller that asked for a particular initial branch and got AlreadyExisted == true did not get the branch it asked for:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathtarget=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitInitResultinit=awaitclient.Init(target).WithInitialBranch("main".As<GitBranchName>()).ExecuteAsync();GitRepositoryrepository=init.Repository;

Clone builds git clone. Its destination pre-check is advisory only — git enforces the same rule itself, so the check exists solely to fail a doomed clone before it pays its network cost, and it is deliberately racy:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;GitRepositoryRemotePathsource="https://github.com/ktsu-dev/GitIntegration.git".As<GitRepositoryRemotePath>();AbsoluteDirectoryPathdestination=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepositorycloned=awaitclient.Clone(source,destination).WithDepth(1).ReportingProgress(newProgress<string>(line =>Console.WriteLine(line))).ExecuteAsync();

Staging and Committing Changes

Commit runs git twice: git commit itself, then git log -1 with this library's pinned format, because commit's own output is a human summary carrying only an abbreviated object id, with no machine-readable alternative:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.Add().All().ExecuteAsync();GitCommitcommit=awaitrepository.Commit("Add feature X".As<GitCommitMessage>()).WithBody("Longer explanation of the change.").WithAuthor("Ada Lovelace".As<GitAuthorName>(),"ada@example.com".As<GitAuthorEmail>()).ExecuteAsync();Console.WriteLine(commit.Sha.WeakString);

Committing with nothing staged throws GitNothingToCommitException, a GitCommandException specialization, rather than the generic base type — the one commit failure that is an ordinary program state rather than a fault:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Commit("Nothing changed".As<GitCommitMessage>()).ExecuteAsync();}catch(GitNothingToCommitException){Console.WriteLine("Nothing was staged; skipping this commit.");}

Creating Branches and Switching

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.CreateBranch("feature/new-thing".As<GitBranchName>()).StartingAt("main".As<GitRefName>()).ExecuteAsync();awaitrepository.Checkout("feature/new-thing".As<GitRefName>()).ExecuteAsync();// Later, once the branch is no longer needed:awaitrepository.DeleteBranch("feature/new-thing".As<GitBranchName>()).Force().ExecuteAsync();

Managing Remotes

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryRemotePathurl="https://github.com/example/repo.git".As<GitRepositoryRemotePath>();awaitrepository.AddRemote("upstream".As<GitRemoteName>(),url).WithFetch().ExecuteAsync();awaitrepository.SetRemoteUrl("upstream".As<GitRemoteName>(),url).ForPushOnly().ExecuteAsync();awaitrepository.RemoveRemote("upstream".As<GitRemoteName>()).ExecuteAsync();

Fetching

fetch --porcelain is only available from git 2.41 onward, so Fetch() probes the installed git's version first. Below that threshold the fetch still runs and still succeeds, but GitFetchResult.DetailAvailable is false and Updates is empty — check DetailAvailable before trusting an empty Updates as "nothing changed":

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitFetchResultfetched=awaitrepository.Fetch().FromRemote("origin".As<GitRemoteName>()).Prune().WithTags().ExecuteAsync();if(fetched.DetailAvailable){Console.WriteLine(fetched.IsUpToDate?"Already up to date.":$"{fetched.Updates.Count} reference(s) updated.");}else{Console.WriteLine("Fetch completed, but this git is older than 2.41 so no per-reference detail is available.");}

Pulling

Pull() returns GitCompleted rather than a parsed result, because everything git pull prints is human prose with no porcelain form. Use Status() and Log() afterwards to learn what changed. A merge conflict is the one outcome with its own exception, GitPullConflictException, because it leaves the repository mid-merge:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Pull().FromRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).FastForwardOnly().ExecuteAsync();}catch(GitPullConflictException){GitStatusstatus=awaitrepository.Status().ExecuteAsync();IEnumerable<GitStatusEntry>unmerged=status.Entries.Where(e =>e.IndexState==GitFileState.Unmerged);Console.WriteLine($"Pull left {unmerged.Count()} unmerged path(s); resolve them and commit.");}

FastForwardOnly() and Rebase() are mutually exclusive — combining them throws InvalidOperationException when the argument vector is built, since they mean opposite things about history.

Pushing — Why ExecuteAsync and TryExecuteAsync Disagree

push is the one verb in this library where the two entry points mean genuinely different things, not just exception-versus-result. A rejected push exits non-zero from git and prints a complete porcelain record of every reference — git got far enough to talk about them and refused some. A caller who does not know this will get it wrong by assuming TryExecuteAsync returning Success means the push landed:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;// ExecuteAsync stays strict: a rejection throws, and the exception carries the full parsed result.try{GitPushResultpushed=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).SettingUpstream().ExecuteAsync();}catch(GitPushRejectedExceptionex){// ex.Result is the same GitPushResult a successful push would have returned.foreach(GitRefUpdateupdateinex.Result!.Updates.Where(u =>u.IsRejected)){Console.WriteLine($"{update.Reference.WeakString}: {update.Summary}");}}

TryExecuteAsync does not throw for a rejection — git ran and reported exactly what happened, so that report comes back as a successful GitResult. Always check HasRejections, not just Success:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitPushResult>result=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).TryExecuteAsync();if(result.Success&&result.Value!.HasRejections){// This is still result.Success == true: TryExecuteAsync only fails when git never reached// the remote at all. A rejection is reported as data, not as GitResult failure.Console.WriteLine("Push ran but at least one reference was rejected — check result.Value.Updates.");}

ForceWithLease() wins over Force() when both are set, being the safer of the two — it refuses if the remote moved since it was last fetched.

Resolving a Revision Without Throwing

TryExecuteAsync reports a non-zero exit as a result instead of an exception — useful when "no such revision" is an expected outcome rather than a failure:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitCommitSha>result=awaitrepository.RevParse("maybe-missing-branch".As<GitRefName>()).TryExecuteAsync();if(result.Success){Console.WriteLine(result.Value!.WeakString);}else{Console.WriteLine($"git exited {result.Error!.ExitCode}: {result.Error.StandardError}");}

Reproducing a Failing Command

ExecuteAsync throws GitCommandException on a non-zero exit, carrying the exact argument vector git was invoked with:

try{awaitrepository.RevParse("no-such-ref".As<GitRefName>()).ExecuteAsync();}catch(GitCommandExceptionex){// ex.Arguments already begins with "-C <path>", so this can be pasted straight after `git`// on a command line to reproduce the failure exactly.Console.WriteLine("git "+string.Join(' ',ex.Arguments));}

Working with a Hosting Provider

Providers are constructed directly rather than resolved from DI: GitHubProvider and AzureDevOpsProvider need only a caller-supplied Owner (and, for Azure DevOps, an optional Project), so there is nothing a container would meaningfully wire up.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProvidergithub=newGitHubProvider{Owner="ktsu-dev".As<GitProviderOwner>(),};// Credentials come from ktsu.CredentialCache, keyed by PersonaGUID — nothing to configure here// unless a specific persona is needed.IReadOnlyList<GitRepository>repositories=awaitgithub.GetRepositoriesAsync();

GitHubProvider.GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones. Azure DevOps has no equivalent restriction: it returns everything the resolved credential can see.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProviderazure=newAzureDevOpsProvider{Owner="my-org".As<GitProviderOwner>(),Project="my-project".As<AzureDevOpsProjectName>(),};IReadOnlyList<GitRepository>repositories=awaitazure.GetRepositoriesAsync();

Project is only required for pull request operations — Azure DevOps has no project-less pull request endpoint, and calling GetPullRequestsAsync or CreatePullRequest without it throws InvalidOperationException immediately. GetPullRequestsAsync returns open pull requests only, on both hosts:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryNamerepositoryName="GitIntegration".As<GitRepositoryName>();IReadOnlyList<GitPullRequest>openPullRequests=awaitazure.GetPullRequestsAsync(repositoryName);foreach(GitPullRequestpullRequestinopenPullRequests){Console.WriteLine($"#{pullRequest.Number.WeakString}{pullRequest.Title.WeakString} ({pullRequest.State})");}

Creating a pull request goes through a builder, the same idiom as the local layer's mutating verbs:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitPullRequestcreated=awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).Describing("Longer explanation of the change.").ExecuteAsync();Console.WriteLine(created.WebURI?.WeakString);

A hosting failure — authentication, not found, rate limiting, or anything else the host reports — surfaces as a GitHostingException subtype carrying the provider name, HTTP status, and response body, mirroring how GitCommandException carries the argument vector for the local layer:

try{awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).ExecuteAsync();}catch(GitHostingAuthenticationExceptionex){Console.WriteLine($"{ex.ProviderName} rejected the request: {ex.StatusCode}");}catch(GitHostingRateLimitExceptionex){Console.WriteLine($"Rate limited until {ex.ResetsAt}.");}

Working with Semantic Types

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitBranchNamebranch="main".As<GitBranchName>();GitRemoteNameremote="origin".As<GitRemoteName>();GitCommitShasha="9fceb02d0ae598e95dc970b74767f19372d61af8".As<GitCommitSha>();// These are distinct types — passing a GitBranchName where a GitCommitSha// is expected is a compile error, not a runtime surprise.

Advanced Usage

Argument Vectors Are Inspectable Before They Run

Every builder's BuildArguments() is a pure computation with no I/O, so the exact command can be asserted or logged before it executes:

IReadOnlyList<string>arguments=repository.Status().BuildArguments();// ["-C", "<path>", "--no-pager", "-c", "core.quotepath=false", "-c", "color.ui=false",// "status", "--porcelain=v2", "--branch", "-z"]

Metadata-Only Repositories

A GitRepository produced by a hosting provider (rather than IGitClient.OpenAsync or DiscoverAsync) carries hosting metadata but no ProcessRunner. Calling any verb on it throws InvalidOperationException immediately, rather than failing later inside git:

GitRepositorymetadataOnly=new(){LocalPath=somePath,Name="GitIntegration".As<GitRepositoryName>()};// Throws InvalidOperationException — obtain a runnable repository from IGitClient first._=metadataOnly.Status();

API Reference

IGitClient / GitClient

The entry point to the local layer: finds and opens repositories, and reports on the git binary.

Methods

NameReturn TypeDescription
GetVersionAsync(CancellationToken)Task<GitVersion>Reports the version of the git binary being invoked.
IsRepositoryAsync(AbsoluteDirectoryPath, CancellationToken)Task<bool>Decides whether a path is inside a git working tree. Never throws for a non-repository path.
OpenAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository>Opens the repository containing a path. Throws GitRepositoryNotFoundException when there is none.
DiscoverAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository?>Opens the repository containing a path, returning null instead of throwing when there is none.
Init(AbsoluteDirectoryPath)IGitInitBuilderCreates a repository at a path. Probes first, so the result's AlreadyExisted can tell a caller whether one was already there.
Clone(GitRepositoryRemotePath, AbsoluteDirectoryPath)IGitCloneBuilderClones a repository into a local working copy.
Clone(GitRepository)IGitCloneBuilderClones the repository a hosting provider described, using its RemotePath and intended LocalPath.

GitRepository

Carries LocalPath plus optional hosting metadata, and exposes one builder factory per verb.

Properties

NameTypeDescription
LocalPathAbsoluteDirectoryPathThe working tree's local filesystem path.
NameGitRepositoryName?The repository name, when known.
WebURIGitRepositoryWebURI?The browser-facing URI, when known.
RemotePathGitRepositoryRemotePath?The remote clone path, when known.
ProcessRunnerIGitProcessRunner?The runner this repository's verbs execute through; null on a metadata-only repository.

Methods

NameReturn TypeDescription
Status()IGitStatusBuilderBuilds git status --porcelain=v2 --branch -z.
Log()IGitLogBuilderBuilds git log -z with this library's pinned format.
Diff()IGitDiffBuilderBuilds git diff --name-status -z.
Branches()IGitBranchListBuilderBuilds git for-each-ref over the branch namespaces.
Remotes()IGitRemoteListBuilderBuilds git remote -v.
RevParse(GitRefName)IGitRevParseBuilderBuilds git rev-parse --verify for a revision.
Add()IGitAddBuilderBuilds git add.
Commit(GitCommitMessage)IGitCommitBuilderBuilds git commit, then reads the new commit back with git log -1.
CreateBranch(GitBranchName)IGitBranchCreateBuilderBuilds git branch <name> [<start-point>].
DeleteBranch(GitBranchName)IGitBranchDeleteBuilderBuilds git branch --delete <name>.
Checkout(GitRefName)IGitCheckoutBuilderBuilds git checkout.
AddRemote(GitRemoteName, GitRepositoryRemotePath)IGitRemoteAddBuilderBuilds git remote add <name> <url>.
RemoveRemote(GitRemoteName)IGitRemoteRemoveBuilderBuilds git remote remove <name>.
SetRemoteUrl(GitRemoteName, GitRepositoryRemotePath)IGitRemoteSetUrlBuilderBuilds git remote set-url <name> <url>.
Fetch()IGitFetchBuilderBuilds git fetch, with --porcelain where the installed git supports it.
Pull()IGitPullBuilderBuilds git pull.
Push()IGitPushBuilderBuilds git push --porcelain.
IsClonedAsync(CancellationToken)Task<bool>Decides whether LocalPath currently holds a git working tree.
OpenWebClient()voidOpens WebURI in the default browser, when it is an absolute http/https URI.

IGitCommandBuilder<TResult>

The shared contract every verb builder implements. A builder is single-use and not thread-safe.

Methods

NameReturn TypeDescription
BuildArguments()IReadOnlyList<string>The exact argument vector this builder will pass to git. Pure, no I/O.
ExecuteAsync(CancellationToken)Task<TResult>Runs the command, throwing GitCommandException when git exits non-zero.
TryExecuteAsync(CancellationToken)Task<GitResult<TResult>>Runs the command, reporting a non-zero exit as a result instead of throwing.

Verb Builders

InterfaceExtra MethodsResult
IGitStatusBuilderWithUntrackedFiles(GitUntrackedFilesMode), IncludeIgnored()GitStatus
IGitLogBuilderTake(int), Skip(int), ForRevision(GitRefName), ForPath(RelativeFilePath), FirstParentOnly()IReadOnlyList<GitCommit>
IGitDiffBuilderStaged(), Against(GitRefName), Between(GitRefName, GitRefName), DetectRenames(), DetectCopies(), ForPath(RelativeFilePath)IReadOnlyList<GitDiffEntry>
IGitBranchListBuilderLocalOnly(), RemoteOnly()IReadOnlyList<GitBranch>
IGitRemoteListBuilder(none)IReadOnlyList<GitRemote>
IGitRevParseBuilder(none — revision supplied via GitRepository.RevParse)GitCommitSha
IGitInitBuilderBare(), WithInitialBranch(GitBranchName)GitInitResult
IGitCloneBuilderWithBranch(GitBranchName), WithDepth(int), Bare(), ReportingProgress(IProgress<string>)GitRepository
IGitAddBuilderForPath(RelativeFilePath), All(), UpdateTrackedOnly()GitCompleted
IGitCommitBuilderWithBody(string), AllowEmpty(), StageTrackedFiles(), WithAuthor(GitAuthorName, GitAuthorEmail)GitCommit
IGitBranchCreateBuilderStartingAt(GitRefName), Force()GitCompleted
IGitBranchDeleteBuilderForce()GitCompleted
IGitCheckoutBuilderCreatingBranch(), Force(), Detach()GitCompleted
IGitRemoteAddBuilderWithFetch()GitCompleted
IGitRemoteRemoveBuilder(none)GitCompleted
IGitRemoteSetUrlBuilderForPushOnly()GitCompleted
IGitFetchBuilderFromRemote(GitRemoteName), AllRemotes(), Prune(), WithTags(), WithDepth(int), ReportingProgress(IProgress<string>)GitFetchResult
IGitPullBuilderFromRemote(GitRemoteName), WithBranch(GitBranchName), FastForwardOnly(), Rebase(), Prune(), ReportingProgress(IProgress<string>)GitCompleted
IGitPushBuilderToRemote(GitRemoteName), WithBranch(GitBranchName), SettingUpstream(), Force(), ForceWithLease(), DeletingRemoteBranch(), DryRun(), ReportingProgress(IProgress<string>)GitPushResult

Result and Execution Models

TypeDescription
GitOptionsConfigures the git executable path and a per-invocation timeout.
IGitProcessRunnerRuns the git executable with a given argument vector; implemented by RunCommandGitProcessRunner.
GitResult<T>The outcome of a command run with TryExecuteAsync: either Value or Error, never both.
GitCommandErrorThe exit code, argument vector, and standard error of a failed invocation.

Exceptions

TypeThrown When
GitExceptionBase type for every failure originating in this library.
GitExecutableNotFoundExceptionThe git executable could not be started.
GitTimeoutExceptionGit did not complete within the configured GitOptions.Timeout.
GitParseExceptionGit succeeded but produced output the parser could not interpret.
GitCommandExceptionGit ran and exited non-zero. Carries ExitCode, Arguments, and StandardError.
GitRepositoryNotFoundExceptionA GitCommandException specialization: the path is not inside a git working tree.
GitNothingToCommitExceptionA GitCommandException specialization: git commit was run with nothing staged. The one commit failure that is an ordinary program state rather than a fault.
GitPushRejectedExceptionA GitCommandException specialization: git refused at least one reference during push. Carries the parsed Result (GitPushResult) so the rejection detail is not lost.
GitPullConflictExceptionA GitCommandException specialization: pull left conflicts in the working tree. Use Status() to see which paths are unmerged.
GitHostingExceptionBase type for every hosting-layer failure. Does not derive from GitException — it carries HTTP concepts, not process ones. Carries ProviderName, StatusCode, ResponseBody.
GitHostingAuthenticationExceptionA GitHostingException specialization: the host rejected the request as unauthenticated, or the credential no longer grants access.
GitHostingNotFoundExceptionA GitHostingException specialization: the requested resource does not exist, or is not visible to the caller's credentials.
GitHostingRateLimitExceptionA GitHostingException specialization: the caller has exhausted its request quota. Carries ResetsAt, when the host reported one.
GitHostingRequestExceptionA GitHostingException specialization for any other non-success response — a malformed request, a validation failure, or a server-side error.

Result Models

TypeDescription
GitStatusBranch, Upstream, Ahead, Behind, IsDetached, Entries, IsClean.
GitStatusEntryIndexState, WorkTreeState, Path, OriginalPath for one changed path.
GitCommitSha, TreeSha, ParentShas, Author, Committer, Subject, Body.
GitSignatureName, Email, Timestamp recorded on a commit.
GitBranchName, Sha, Upstream, IsCurrent, IsRemote.
GitRemoteName, FetchUrl, PushUrl.
GitDiffEntryKind, Path, OriginalPath, SimilarityPercent.
GitVersionMajor, Minor, Patch, Raw, plus AtLeast(major, minor).
GitFileStateEnum: Unmodified, Modified, Added, Deleted, Renamed, Copied, Untracked, Ignored, Unmerged, TypeChanged.
GitChangeKindEnum: Added, Copied, Deleted, Modified, Renamed, TypeChanged, Unmerged, Unknown.
GitUntrackedFilesModeEnum: No, Normal, All.
GitCompletedThe result of a mutating verb whose only outcome is success — add, checkout, branch creation/deletion, remote commands, and pull. Carries Arguments.
GitInitResultRepository, AlreadyExisted — the outcome of IGitClient.Init.
GitFetchResultUpdates, DetailAvailable, IsUpToDate — the outcome of Fetch(). IsUpToDate is gated on DetailAvailable so an empty Updates from a pre-2.41 git is never mistaken for "nothing changed".
GitPushResultUpdates, HasRejections — the outcome of Push().
GitRefUpdateKind, Reference, Source, OldSha, NewSha, Summary, IsRejected — one reference changed by a fetch or a push.
GitRefUpdateKindEnum: FastForward, Forced, Removed, Created, Rejected, UpToDate, TagUpdate, Unknown.
GitPullRequestNumber, Title, Description, SourceBranch, TargetBranch, Author, State, IsDraft, WebURI, CreatedAt — one pull request, as reported by a hosting provider. A null optional field means the host did not report that value.
GitPullRequestStateEnum: Open, Merged, Closed.

IGitHostingProvider

The contract every hosting provider implements: repository enumeration, pull request listing, and pull request creation, over whichever transport and authentication scheme the host requires.

Properties

NameTypeDescription
NameGitProviderNameDisplay name of the provider.
OwnerGitProviderOwnerThe owner of the repositories in this provider.
PersonaGUIDPersonaGUIDThe persona GUID used for authentication with the provider (from ktsu.CredentialCache).
IsAuthenticatedboolWhether requests this provider issues carry a credential. A cache entry that resolves to "proceed unauthenticated", and one of a type the provider cannot apply, both report false.

Methods

NameReturn TypeDescription
GetRepositoriesAsync(CancellationToken)Task<IReadOnlyList<GitRepository>>Retrieves the repositories Owner has, from the host. Coverage differs by host — see GitHubProvider and AzureDevOpsProvider below.
GetPullRequestsAsync(GitRepositoryName, CancellationToken)Task<IReadOnlyList<GitPullRequest>>Retrieves a repository's open pull requests. The filter is requested explicitly of the host, not left to its default.
CreatePullRequest(GitRepositoryName)IGitPullRequestCreateBuilderStarts building a pull request for a repository.

GitProvider

The abstract base both hosting providers derive from. Implements IGitHostingProvider and resolves credentials via TryGetCredential/ResolveCredential; each concrete provider builds its own HttpClient-based transport around that credential — see GitHubProvider and AzureDevOpsProvider below for how the two differ. public bool TryGetCredential(out Credential?) is declared here, not on IGitHostingProvider, and reports only whether the credential cache holds an entry for this provider's PersonaGUID. IsAuthenticated is the narrower question of whether a request would actually carry one.

Each provider type keeps one shared SocketsHttpHandler for the life of the process and builds a short-lived client or adapter over it per call, so a caller looping over many repositories reuses one connection pool rather than building and tearing down one per request. Neither provider is IDisposable, and neither ever disposes a handler injected for testing.

GitHubProvider

GitProvider implementation backed by Octokit. GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones, and supplying a token does not widen this.

AzureDevOpsProvider

GitProvider implementation built on a raw HttpClient against Azure DevOps's REST API (api-version=7.1) — no Azure DevOps client library is referenced (see the Introduction). GetPullRequestsAsync pages with $top and $skip until a short page comes back, so a repository with more open pull requests than one page costs more than one request and is never truncated. GetRepositoriesAsync issues exactly one request, because that endpoint documents no pagination.

Additional Properties

NameTypeDescription
ProjectAzureDevOpsProjectName?Scopes repository enumeration to a project, or null to enumerate the whole organisation. Required for GetPullRequestsAsync and CreatePullRequest — Azure DevOps has no project-less pull request endpoint, and calling either without it throws InvalidOperationException.

GetRepositoriesAsync returns everything the resolved credential can see — unlike GitHubProvider, there is no public-only restriction.

IGitPullRequestCreateBuilder

Collects a pull request's details before submitting it. From, Into, and Titled are required; Describing and AsDraft are optional. A missing required value throws InvalidOperationException from ExecuteAsync, not from the setter that left it unset, since parts may be supplied in any order.

NameReturn TypeDescription
From(GitBranchName)IGitPullRequestCreateBuilderSets the source branch.
Into(GitBranchName)IGitPullRequestCreateBuilderSets the target branch.
Titled(GitPullRequestTitle)IGitPullRequestCreateBuilderSets the title.
Describing(string)IGitPullRequestCreateBuilderSets the description.
AsDraft()IGitPullRequestCreateBuilderMarks the pull request as a draft.
ExecuteAsync(CancellationToken)Task<GitPullRequest>Submits the pull request to the host.

ServiceCollectionExtensions

NameReturn TypeDescription
AddGitIntegration(IServiceCollection)IServiceCollectionRegisters git integration with default options, invoking the git found on PATH.
AddGitIntegration(IServiceCollection, Action<GitOptions>)IServiceCollectionRegisters git integration with configured options. Idempotent per service.

Registers only the local layer. Hosting providers are constructed directly — see Working with a Hosting Provider — since neither GitHubProvider nor AzureDevOpsProvider has a constructor dependency a container could supply.

Semantic Types

TypeWraps
GitAuthorEmailCommit author or committer email address
GitAuthorNameCommit author or committer name
GitBranchNameBranch name
GitCommitMessageCommit message
GitCommitShaCommit object id (abbreviated or full, including SHA-256 repositories)
GitProviderNameHosting provider display name
GitProviderOwnerAccount or organization owning a repository
GitRefNameA branch, tag, SHA, or revision expression
GitRemoteNameRemote name
GitRepositoryNameRepository name
GitRepositoryRemotePathClone path or URL
GitRepositoryWebURIRepository web address
AzureDevOpsProjectNameAzure DevOps project name — scopes AzureDevOpsProvider repository enumeration, and required for its pull request operations
GitPullRequestNumberPull request's host-assigned number
GitPullRequestTitlePull request title
GitPullRequestAuthorHost's identifier for the account that opened a pull request (a GitHub login, or an Azure DevOps unique name)
GitPullRequestWebURIPull request web address

Contributing

Contributions are welcome! Feel free to open issues or submit pull requests.

License

This project is licensed under the MIT License. See the LICENSE.md file for details.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ktsu.GitIntegration

A .NET library that wraps the git binary behind a fluent, strongly-typed interface, and unifies access to hosted Git providers behind a single abstraction.

LicenseNuGet VersionNuGet VersionNuGet DownloadsGitHub commit activityGitHub contributorsGitHub Actions Workflow Status

Introduction

ktsu.GitIntegration is a two-layer library. The local layer wraps the git executable found on PATH behind a fluent, strongly-typed interface: open or discover a repository, then build and run both read-only commands (status, log, diff, branches, remotes, rev-parse) and mutating commands (init, clone, add, commit, branch creation and deletion, checkout, remote management, and remote sync via fetch, pull, and push) without shelling out or hand-parsing porcelain output yourself. The hosting layer — the original half of this library — unifies access to hosted Git providers behind a GitProvider abstraction: GitHubProvider, built on Octokit, and AzureDevOpsProvider, built on a raw HttpClient against Azure DevOps's REST API. Both enumerate repositories and list and create pull requests through the same IGitHostingProvider contract.

Every value that would otherwise be a bare string — a branch name, a commit SHA, a remote name, an author email — is instead a validated semantic type built on ktsu.Semantics, so a GitBranchName can no longer be accidentally passed where a GitCommitSha is expected.

Azure DevOps hosting deliberately does not use Microsoft.TeamFoundationServer.Client or the other official TFS/Azure DevOps client package — both pull in System.Data.SqlClient, which carries a known high-severity advisory, as a direct dependency of the published package. AzureDevOpsProvider builds and parses its requests by hand instead.

Features

  • Local Git Client: IGitClient/GitClient finds and opens repositories — GetVersionAsync, IsRepositoryAsync, OpenAsync, DiscoverAsync — and creates new ones — Init(...), Clone(...) — by delegating every invocation to ktsu.RunCommand.
  • Fluent Verb Builders: GitRepository exposes one builder per read-only verb — Status(), Log(), Diff(), Branches(), Remotes(), RevParse(...) — and one per mutating verb — Add(), Commit(...), CreateBranch(...), DeleteBranch(...), Checkout(...), AddRemote(...), RemoveRemote(...), SetRemoteUrl(...), Fetch(), Pull(), Push() — each configurable via chained method calls and run with ExecuteAsync or the non-throwing TryExecuteAsync.
  • Remote Sync: Fetch() downloads objects and refs without touching the working tree, Pull() fetches and integrates into the current branch, and Push() sends local commits — fetch and push report a machine-readable, per-reference account of what happened via GitFetchResult/GitPushResult, and a rejected push is the one place in this library where ExecuteAsync and TryExecuteAsync diverge in more than exception-versus-result.
  • Strongly-Typed Results: GitStatus, GitCommit, GitBranch, GitRemote, GitDiffEntry, GitVersion, GitInitResult, GitCompleted, GitFetchResult, GitPushResult, and GitRefUpdate records replace ad-hoc porcelain parsing with typed models — GitCompleted is the shared result for mutating verbs whose only outcome is success.
  • Reproducible Failures: every command is scoped with git -C <path> instead of a process working directory, so a failing invocation's exact argument vector can be read off a GitCommandException and rerun verbatim.
  • Locale-Safe Parsing: every invocation runs with GIT_TERMINAL_PROMPT=0 (no hanging credential prompts) and LC_ALL=C (English, machine-stable output), which is what makes the output parsers safe to write against fixed English text.
  • Dependency Injection: AddGitIntegration() registers the client, process runner, and options as singletons in one call. The hosting layer is constructed directly instead — see Working with a Hosting Provider.
  • Hosting Provider Abstraction: IGitHostingProvider defines a common contract for enumerating repositories, listing open pull requests, and creating a pull request — GitHubProvider implements it on top of Octokit, AzureDevOpsProvider on a raw HttpClient against Azure DevOps's REST API.
  • Credential Resolution: hosting providers integrate with ktsu.CredentialCache, so credentials come from the host's native keyring rather than configuration files.
  • Semantic Git Types: validated wrapper types for every identifier Git tooling passes around, so mismatched arguments fail at compile time rather than at runtime.

Installation

Package Manager Console

Install-Package ktsu.GitIntegration

.NET CLI

dotnet add package ktsu.GitIntegration

Package Reference

<PackageReferenceInclude="ktsu.GitIntegration"Version="x.y.z" />

Usage Examples

Basic Example

Register the library with dependency injection, then resolve IGitClient:

usingktsu.GitIntegration;usingMicrosoft.Extensions.DependencyInjection;ServiceCollectionservices=new();services.AddGitIntegration();usingServiceProviderprovider=services.BuildServiceProvider();IGitClientclient=provider.GetRequiredService<IGitClient>();

Discovering a Repository and Reading Its Status

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathhere=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepository?repository=awaitclient.DiscoverAsync(here);if(repositoryis not null){GitStatusstatus=awaitrepository.Status().ExecuteAsync();Console.WriteLine(status.IsClean?"Working tree is clean.":$"{status.Entries.Count} changed path(s) on {status.Branch?.WeakString}.");}

Listing Commits and Diffs

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IReadOnlyList<GitCommit>commits=awaitrepository.Log().Take(10).FirstParentOnly().ExecuteAsync();foreach(GitCommitcommitincommits){Console.WriteLine($"{commit.Sha.WeakString[..7]}{commit.Subject}");}IReadOnlyList<GitDiffEntry>changes=awaitrepository.Diff().Staged().DetectRenames().ExecuteAsync();

Initializing or Cloning a Repository

Init probes the target path before running git init, so GitInitResult.AlreadyExisted can tell a caller whether a repository was already there — git init is idempotent and silently ignores --initial-branch when re-initialising, so a caller that asked for a particular initial branch and got AlreadyExisted == true did not get the branch it asked for:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathtarget=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitInitResultinit=awaitclient.Init(target).WithInitialBranch("main".As<GitBranchName>()).ExecuteAsync();GitRepositoryrepository=init.Repository;

Clone builds git clone. Its destination pre-check is advisory only — git enforces the same rule itself, so the check exists solely to fail a doomed clone before it pays its network cost, and it is deliberately racy:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;GitRepositoryRemotePathsource="https://github.com/ktsu-dev/GitIntegration.git".As<GitRepositoryRemotePath>();AbsoluteDirectoryPathdestination=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepositorycloned=awaitclient.Clone(source,destination).WithDepth(1).ReportingProgress(newProgress<string>(line =>Console.WriteLine(line))).ExecuteAsync();

Staging and Committing Changes

Commit runs git twice: git commit itself, then git log -1 with this library's pinned format, because commit's own output is a human summary carrying only an abbreviated object id, with no machine-readable alternative:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.Add().All().ExecuteAsync();GitCommitcommit=awaitrepository.Commit("Add feature X".As<GitCommitMessage>()).WithBody("Longer explanation of the change.").WithAuthor("Ada Lovelace".As<GitAuthorName>(),"ada@example.com".As<GitAuthorEmail>()).ExecuteAsync();Console.WriteLine(commit.Sha.WeakString);

Committing with nothing staged throws GitNothingToCommitException, a GitCommandException specialization, rather than the generic base type — the one commit failure that is an ordinary program state rather than a fault:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Commit("Nothing changed".As<GitCommitMessage>()).ExecuteAsync();}catch(GitNothingToCommitException){Console.WriteLine("Nothing was staged; skipping this commit.");}

Creating Branches and Switching

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.CreateBranch("feature/new-thing".As<GitBranchName>()).StartingAt("main".As<GitRefName>()).ExecuteAsync();awaitrepository.Checkout("feature/new-thing".As<GitRefName>()).ExecuteAsync();// Later, once the branch is no longer needed:awaitrepository.DeleteBranch("feature/new-thing".As<GitBranchName>()).Force().ExecuteAsync();

Managing Remotes

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryRemotePathurl="https://github.com/example/repo.git".As<GitRepositoryRemotePath>();awaitrepository.AddRemote("upstream".As<GitRemoteName>(),url).WithFetch().ExecuteAsync();awaitrepository.SetRemoteUrl("upstream".As<GitRemoteName>(),url).ForPushOnly().ExecuteAsync();awaitrepository.RemoveRemote("upstream".As<GitRemoteName>()).ExecuteAsync();

Fetching

fetch --porcelain is only available from git 2.41 onward, so Fetch() probes the installed git's version first. Below that threshold the fetch still runs and still succeeds, but GitFetchResult.DetailAvailable is false and Updates is empty — check DetailAvailable before trusting an empty Updates as "nothing changed":

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitFetchResultfetched=awaitrepository.Fetch().FromRemote("origin".As<GitRemoteName>()).Prune().WithTags().ExecuteAsync();if(fetched.DetailAvailable){Console.WriteLine(fetched.IsUpToDate?"Already up to date.":$"{fetched.Updates.Count} reference(s) updated.");}else{Console.WriteLine("Fetch completed, but this git is older than 2.41 so no per-reference detail is available.");}

Pulling

Pull() returns GitCompleted rather than a parsed result, because everything git pull prints is human prose with no porcelain form. Use Status() and Log() afterwards to learn what changed. A merge conflict is the one outcome with its own exception, GitPullConflictException, because it leaves the repository mid-merge:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Pull().FromRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).FastForwardOnly().ExecuteAsync();}catch(GitPullConflictException){GitStatusstatus=awaitrepository.Status().ExecuteAsync();IEnumerable<GitStatusEntry>unmerged=status.Entries.Where(e =>e.IndexState==GitFileState.Unmerged);Console.WriteLine($"Pull left {unmerged.Count()} unmerged path(s); resolve them and commit.");}

FastForwardOnly() and Rebase() are mutually exclusive — combining them throws InvalidOperationException when the argument vector is built, since they mean opposite things about history.

Pushing — Why ExecuteAsync and TryExecuteAsync Disagree

push is the one verb in this library where the two entry points mean genuinely different things, not just exception-versus-result. A rejected push exits non-zero from git and prints a complete porcelain record of every reference — git got far enough to talk about them and refused some. A caller who does not know this will get it wrong by assuming TryExecuteAsync returning Success means the push landed:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;// ExecuteAsync stays strict: a rejection throws, and the exception carries the full parsed result.try{GitPushResultpushed=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).SettingUpstream().ExecuteAsync();}catch(GitPushRejectedExceptionex){// ex.Result is the same GitPushResult a successful push would have returned.foreach(GitRefUpdateupdateinex.Result!.Updates.Where(u =>u.IsRejected)){Console.WriteLine($"{update.Reference.WeakString}: {update.Summary}");}}

TryExecuteAsync does not throw for a rejection — git ran and reported exactly what happened, so that report comes back as a successful GitResult. Always check HasRejections, not just Success:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitPushResult>result=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).TryExecuteAsync();if(result.Success&&result.Value!.HasRejections){// This is still result.Success == true: TryExecuteAsync only fails when git never reached// the remote at all. A rejection is reported as data, not as GitResult failure.Console.WriteLine("Push ran but at least one reference was rejected — check result.Value.Updates.");}

ForceWithLease() wins over Force() when both are set, being the safer of the two — it refuses if the remote moved since it was last fetched.

Resolving a Revision Without Throwing

TryExecuteAsync reports a non-zero exit as a result instead of an exception — useful when "no such revision" is an expected outcome rather than a failure:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitCommitSha>result=awaitrepository.RevParse("maybe-missing-branch".As<GitRefName>()).TryExecuteAsync();if(result.Success){Console.WriteLine(result.Value!.WeakString);}else{Console.WriteLine($"git exited {result.Error!.ExitCode}: {result.Error.StandardError}");}

Reproducing a Failing Command

ExecuteAsync throws GitCommandException on a non-zero exit, carrying the exact argument vector git was invoked with:

try{awaitrepository.RevParse("no-such-ref".As<GitRefName>()).ExecuteAsync();}catch(GitCommandExceptionex){// ex.Arguments already begins with "-C <path>", so this can be pasted straight after `git`// on a command line to reproduce the failure exactly.Console.WriteLine("git "+string.Join(' ',ex.Arguments));}

Working with a Hosting Provider

Providers are constructed directly rather than resolved from DI: GitHubProvider and AzureDevOpsProvider need only a caller-supplied Owner (and, for Azure DevOps, an optional Project), so there is nothing a container would meaningfully wire up.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProvidergithub=newGitHubProvider{Owner="ktsu-dev".As<GitProviderOwner>(),};// Credentials come from ktsu.CredentialCache, keyed by PersonaGUID — nothing to configure here// unless a specific persona is needed.IReadOnlyList<GitRepository>repositories=awaitgithub.GetRepositoriesAsync();

GitHubProvider.GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones. Azure DevOps has no equivalent restriction: it returns everything the resolved credential can see.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProviderazure=newAzureDevOpsProvider{Owner="my-org".As<GitProviderOwner>(),Project="my-project".As<AzureDevOpsProjectName>(),};IReadOnlyList<GitRepository>repositories=awaitazure.GetRepositoriesAsync();

Project is only required for pull request operations — Azure DevOps has no project-less pull request endpoint, and calling GetPullRequestsAsync or CreatePullRequest without it throws InvalidOperationException immediately. GetPullRequestsAsync returns open pull requests only, on both hosts:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryNamerepositoryName="GitIntegration".As<GitRepositoryName>();IReadOnlyList<GitPullRequest>openPullRequests=awaitazure.GetPullRequestsAsync(repositoryName);foreach(GitPullRequestpullRequestinopenPullRequests){Console.WriteLine($"#{pullRequest.Number.WeakString}{pullRequest.Title.WeakString} ({pullRequest.State})");}

Creating a pull request goes through a builder, the same idiom as the local layer's mutating verbs:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitPullRequestcreated=awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).Describing("Longer explanation of the change.").ExecuteAsync();Console.WriteLine(created.WebURI?.WeakString);

A hosting failure — authentication, not found, rate limiting, or anything else the host reports — surfaces as a GitHostingException subtype carrying the provider name, HTTP status, and response body, mirroring how GitCommandException carries the argument vector for the local layer:

try{awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).ExecuteAsync();}catch(GitHostingAuthenticationExceptionex){Console.WriteLine($"{ex.ProviderName} rejected the request: {ex.StatusCode}");}catch(GitHostingRateLimitExceptionex){Console.WriteLine($"Rate limited until {ex.ResetsAt}.");}

Working with Semantic Types

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitBranchNamebranch="main".As<GitBranchName>();GitRemoteNameremote="origin".As<GitRemoteName>();GitCommitShasha="9fceb02d0ae598e95dc970b74767f19372d61af8".As<GitCommitSha>();// These are distinct types — passing a GitBranchName where a GitCommitSha// is expected is a compile error, not a runtime surprise.

Advanced Usage

Argument Vectors Are Inspectable Before They Run

Every builder's BuildArguments() is a pure computation with no I/O, so the exact command can be asserted or logged before it executes:

IReadOnlyList<string>arguments=repository.Status().BuildArguments();// ["-C", "<path>", "--no-pager", "-c", "core.quotepath=false", "-c", "color.ui=false",// "status", "--porcelain=v2", "--branch", "-z"]

Metadata-Only Repositories

A GitRepository produced by a hosting provider (rather than IGitClient.OpenAsync or DiscoverAsync) carries hosting metadata but no ProcessRunner. Calling any verb on it throws InvalidOperationException immediately, rather than failing later inside git:

GitRepositorymetadataOnly=new(){LocalPath=somePath,Name="GitIntegration".As<GitRepositoryName>()};// Throws InvalidOperationException — obtain a runnable repository from IGitClient first._=metadataOnly.Status();

API Reference

IGitClient / GitClient

The entry point to the local layer: finds and opens repositories, and reports on the git binary.

Methods

NameReturn TypeDescription
GetVersionAsync(CancellationToken)Task<GitVersion>Reports the version of the git binary being invoked.
IsRepositoryAsync(AbsoluteDirectoryPath, CancellationToken)Task<bool>Decides whether a path is inside a git working tree. Never throws for a non-repository path.
OpenAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository>Opens the repository containing a path. Throws GitRepositoryNotFoundException when there is none.
DiscoverAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository?>Opens the repository containing a path, returning null instead of throwing when there is none.
Init(AbsoluteDirectoryPath)IGitInitBuilderCreates a repository at a path. Probes first, so the result's AlreadyExisted can tell a caller whether one was already there.
Clone(GitRepositoryRemotePath, AbsoluteDirectoryPath)IGitCloneBuilderClones a repository into a local working copy.
Clone(GitRepository)IGitCloneBuilderClones the repository a hosting provider described, using its RemotePath and intended LocalPath.

GitRepository

Carries LocalPath plus optional hosting metadata, and exposes one builder factory per verb.

Properties

NameTypeDescription
LocalPathAbsoluteDirectoryPathThe working tree's local filesystem path.
NameGitRepositoryName?The repository name, when known.
WebURIGitRepositoryWebURI?The browser-facing URI, when known.
RemotePathGitRepositoryRemotePath?The remote clone path, when known.
ProcessRunnerIGitProcessRunner?The runner this repository's verbs execute through; null on a metadata-only repository.

Methods

NameReturn TypeDescription
Status()IGitStatusBuilderBuilds git status --porcelain=v2 --branch -z.
Log()IGitLogBuilderBuilds git log -z with this library's pinned format.
Diff()IGitDiffBuilderBuilds git diff --name-status -z.
Branches()IGitBranchListBuilderBuilds git for-each-ref over the branch namespaces.
Remotes()IGitRemoteListBuilderBuilds git remote -v.
RevParse(GitRefName)IGitRevParseBuilderBuilds git rev-parse --verify for a revision.
Add()IGitAddBuilderBuilds git add.
Commit(GitCommitMessage)IGitCommitBuilderBuilds git commit, then reads the new commit back with git log -1.
CreateBranch(GitBranchName)IGitBranchCreateBuilderBuilds git branch <name> [<start-point>].
DeleteBranch(GitBranchName)IGitBranchDeleteBuilderBuilds git branch --delete <name>.
Checkout(GitRefName)IGitCheckoutBuilderBuilds git checkout.
AddRemote(GitRemoteName, GitRepositoryRemotePath)IGitRemoteAddBuilderBuilds git remote add <name> <url>.
RemoveRemote(GitRemoteName)IGitRemoteRemoveBuilderBuilds git remote remove <name>.
SetRemoteUrl(GitRemoteName, GitRepositoryRemotePath)IGitRemoteSetUrlBuilderBuilds git remote set-url <name> <url>.
Fetch()IGitFetchBuilderBuilds git fetch, with --porcelain where the installed git supports it.
Pull()IGitPullBuilderBuilds git pull.
Push()IGitPushBuilderBuilds git push --porcelain.
IsClonedAsync(CancellationToken)Task<bool>Decides whether LocalPath currently holds a git working tree.
OpenWebClient()voidOpens WebURI in the default browser, when it is an absolute http/https URI.

IGitCommandBuilder<TResult>

The shared contract every verb builder implements. A builder is single-use and not thread-safe.

Methods

NameReturn TypeDescription
BuildArguments()IReadOnlyList<string>The exact argument vector this builder will pass to git. Pure, no I/O.
ExecuteAsync(CancellationToken)Task<TResult>Runs the command, throwing GitCommandException when git exits non-zero.
TryExecuteAsync(CancellationToken)Task<GitResult<TResult>>Runs the command, reporting a non-zero exit as a result instead of throwing.

Verb Builders

InterfaceExtra MethodsResult
IGitStatusBuilderWithUntrackedFiles(GitUntrackedFilesMode), IncludeIgnored()GitStatus
IGitLogBuilderTake(int), Skip(int), ForRevision(GitRefName), ForPath(RelativeFilePath), FirstParentOnly()IReadOnlyList<GitCommit>
IGitDiffBuilderStaged(), Against(GitRefName), Between(GitRefName, GitRefName), DetectRenames(), DetectCopies(), ForPath(RelativeFilePath)IReadOnlyList<GitDiffEntry>
IGitBranchListBuilderLocalOnly(), RemoteOnly()IReadOnlyList<GitBranch>
IGitRemoteListBuilder(none)IReadOnlyList<GitRemote>
IGitRevParseBuilder(none — revision supplied via GitRepository.RevParse)GitCommitSha
IGitInitBuilderBare(), WithInitialBranch(GitBranchName)GitInitResult
IGitCloneBuilderWithBranch(GitBranchName), WithDepth(int), Bare(), ReportingProgress(IProgress<string>)GitRepository
IGitAddBuilderForPath(RelativeFilePath), All(), UpdateTrackedOnly()GitCompleted
IGitCommitBuilderWithBody(string), AllowEmpty(), StageTrackedFiles(), WithAuthor(GitAuthorName, GitAuthorEmail)GitCommit
IGitBranchCreateBuilderStartingAt(GitRefName), Force()GitCompleted
IGitBranchDeleteBuilderForce()GitCompleted
IGitCheckoutBuilderCreatingBranch(), Force(), Detach()GitCompleted
IGitRemoteAddBuilderWithFetch()GitCompleted
IGitRemoteRemoveBuilder(none)GitCompleted
IGitRemoteSetUrlBuilderForPushOnly()GitCompleted
IGitFetchBuilderFromRemote(GitRemoteName), AllRemotes(), Prune(), WithTags(), WithDepth(int), ReportingProgress(IProgress<string>)GitFetchResult
IGitPullBuilderFromRemote(GitRemoteName), WithBranch(GitBranchName), FastForwardOnly(), Rebase(), Prune(), ReportingProgress(IProgress<string>)GitCompleted
IGitPushBuilderToRemote(GitRemoteName), WithBranch(GitBranchName), SettingUpstream(), Force(), ForceWithLease(), DeletingRemoteBranch(), DryRun(), ReportingProgress(IProgress<string>)GitPushResult

Result and Execution Models

TypeDescription
GitOptionsConfigures the git executable path and a per-invocation timeout.
IGitProcessRunnerRuns the git executable with a given argument vector; implemented by RunCommandGitProcessRunner.
GitResult<T>The outcome of a command run with TryExecuteAsync: either Value or Error, never both.
GitCommandErrorThe exit code, argument vector, and standard error of a failed invocation.

Exceptions

TypeThrown When
GitExceptionBase type for every failure originating in this library.
GitExecutableNotFoundExceptionThe git executable could not be started.
GitTimeoutExceptionGit did not complete within the configured GitOptions.Timeout.
GitParseExceptionGit succeeded but produced output the parser could not interpret.
GitCommandExceptionGit ran and exited non-zero. Carries ExitCode, Arguments, and StandardError.
GitRepositoryNotFoundExceptionA GitCommandException specialization: the path is not inside a git working tree.
GitNothingToCommitExceptionA GitCommandException specialization: git commit was run with nothing staged. The one commit failure that is an ordinary program state rather than a fault.
GitPushRejectedExceptionA GitCommandException specialization: git refused at least one reference during push. Carries the parsed Result (GitPushResult) so the rejection detail is not lost.
GitPullConflictExceptionA GitCommandException specialization: pull left conflicts in the working tree. Use Status() to see which paths are unmerged.
GitHostingExceptionBase type for every hosting-layer failure. Does not derive from GitException — it carries HTTP concepts, not process ones. Carries ProviderName, StatusCode, ResponseBody.
GitHostingAuthenticationExceptionA GitHostingException specialization: the host rejected the request as unauthenticated, or the credential no longer grants access.
GitHostingNotFoundExceptionA GitHostingException specialization: the requested resource does not exist, or is not visible to the caller's credentials.
GitHostingRateLimitExceptionA GitHostingException specialization: the caller has exhausted its request quota. Carries ResetsAt, when the host reported one.
GitHostingRequestExceptionA GitHostingException specialization for any other non-success response — a malformed request, a validation failure, or a server-side error.

Result Models

TypeDescription
GitStatusBranch, Upstream, Ahead, Behind, IsDetached, Entries, IsClean.
GitStatusEntryIndexState, WorkTreeState, Path, OriginalPath for one changed path.
GitCommitSha, TreeSha, ParentShas, Author, Committer, Subject, Body.
GitSignatureName, Email, Timestamp recorded on a commit.
GitBranchName, Sha, Upstream, IsCurrent, IsRemote.
GitRemoteName, FetchUrl, PushUrl.
GitDiffEntryKind, Path, OriginalPath, SimilarityPercent.
GitVersionMajor, Minor, Patch, Raw, plus AtLeast(major, minor).
GitFileStateEnum: Unmodified, Modified, Added, Deleted, Renamed, Copied, Untracked, Ignored, Unmerged, TypeChanged.
GitChangeKindEnum: Added, Copied, Deleted, Modified, Renamed, TypeChanged, Unmerged, Unknown.
GitUntrackedFilesModeEnum: No, Normal, All.
GitCompletedThe result of a mutating verb whose only outcome is success — add, checkout, branch creation/deletion, remote commands, and pull. Carries Arguments.
GitInitResultRepository, AlreadyExisted — the outcome of IGitClient.Init.
GitFetchResultUpdates, DetailAvailable, IsUpToDate — the outcome of Fetch(). IsUpToDate is gated on DetailAvailable so an empty Updates from a pre-2.41 git is never mistaken for "nothing changed".
GitPushResultUpdates, HasRejections — the outcome of Push().
GitRefUpdateKind, Reference, Source, OldSha, NewSha, Summary, IsRejected — one reference changed by a fetch or a push.
GitRefUpdateKindEnum: FastForward, Forced, Removed, Created, Rejected, UpToDate, TagUpdate, Unknown.
GitPullRequestNumber, Title, Description, SourceBranch, TargetBranch, Author, State, IsDraft, WebURI, CreatedAt — one pull request, as reported by a hosting provider. A null optional field means the host did not report that value.
GitPullRequestStateEnum: Open, Merged, Closed.

IGitHostingProvider

The contract every hosting provider implements: repository enumeration, pull request listing, and pull request creation, over whichever transport and authentication scheme the host requires.

Properties

NameTypeDescription
NameGitProviderNameDisplay name of the provider.
OwnerGitProviderOwnerThe owner of the repositories in this provider.
PersonaGUIDPersonaGUIDThe persona GUID used for authentication with the provider (from ktsu.CredentialCache).
IsAuthenticatedboolWhether requests this provider issues carry a credential. A cache entry that resolves to "proceed unauthenticated", and one of a type the provider cannot apply, both report false.

Methods

NameReturn TypeDescription
GetRepositoriesAsync(CancellationToken)Task<IReadOnlyList<GitRepository>>Retrieves the repositories Owner has, from the host. Coverage differs by host — see GitHubProvider and AzureDevOpsProvider below.
GetPullRequestsAsync(GitRepositoryName, CancellationToken)Task<IReadOnlyList<GitPullRequest>>Retrieves a repository's open pull requests. The filter is requested explicitly of the host, not left to its default.
CreatePullRequest(GitRepositoryName)IGitPullRequestCreateBuilderStarts building a pull request for a repository.

GitProvider

The abstract base both hosting providers derive from. Implements IGitHostingProvider and resolves credentials via TryGetCredential/ResolveCredential; each concrete provider builds its own HttpClient-based transport around that credential — see GitHubProvider and AzureDevOpsProvider below for how the two differ. public bool TryGetCredential(out Credential?) is declared here, not on IGitHostingProvider, and reports only whether the credential cache holds an entry for this provider's PersonaGUID. IsAuthenticated is the narrower question of whether a request would actually carry one.

Each provider type keeps one shared SocketsHttpHandler for the life of the process and builds a short-lived client or adapter over it per call, so a caller looping over many repositories reuses one connection pool rather than building and tearing down one per request. Neither provider is IDisposable, and neither ever disposes a handler injected for testing.

GitHubProvider

GitProvider implementation backed by Octokit. GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones, and supplying a token does not widen this.

AzureDevOpsProvider

GitProvider implementation built on a raw HttpClient against Azure DevOps's REST API (api-version=7.1) — no Azure DevOps client library is referenced (see the Introduction). GetPullRequestsAsync pages with $top and $skip until a short page comes back, so a repository with more open pull requests than one page costs more than one request and is never truncated. GetRepositoriesAsync issues exactly one request, because that endpoint documents no pagination.

Additional Properties

NameTypeDescription
ProjectAzureDevOpsProjectName?Scopes repository enumeration to a project, or null to enumerate the whole organisation. Required for GetPullRequestsAsync and CreatePullRequest — Azure DevOps has no project-less pull request endpoint, and calling either without it throws InvalidOperationException.

GetRepositoriesAsync returns everything the resolved credential can see — unlike GitHubProvider, there is no public-only restriction.

IGitPullRequestCreateBuilder

Collects a pull request's details before submitting it. From, Into, and Titled are required; Describing and AsDraft are optional. A missing required value throws InvalidOperationException from ExecuteAsync, not from the setter that left it unset, since parts may be supplied in any order.

NameReturn TypeDescription
From(GitBranchName)IGitPullRequestCreateBuilderSets the source branch.
Into(GitBranchName)IGitPullRequestCreateBuilderSets the target branch.
Titled(GitPullRequestTitle)IGitPullRequestCreateBuilderSets the title.
Describing(string)IGitPullRequestCreateBuilderSets the description.
AsDraft()IGitPullRequestCreateBuilderMarks the pull request as a draft.
ExecuteAsync(CancellationToken)Task<GitPullRequest>Submits the pull request to the host.

ServiceCollectionExtensions

NameReturn TypeDescription
AddGitIntegration(IServiceCollection)IServiceCollectionRegisters git integration with default options, invoking the git found on PATH.
AddGitIntegration(IServiceCollection, Action<GitOptions>)IServiceCollectionRegisters git integration with configured options. Idempotent per service.

Registers only the local layer. Hosting providers are constructed directly — see Working with a Hosting Provider — since neither GitHubProvider nor AzureDevOpsProvider has a constructor dependency a container could supply.

Semantic Types

TypeWraps
GitAuthorEmailCommit author or committer email address
GitAuthorNameCommit author or committer name
GitBranchNameBranch name
GitCommitMessageCommit message
GitCommitShaCommit object id (abbreviated or full, including SHA-256 repositories)
GitProviderNameHosting provider display name
GitProviderOwnerAccount or organization owning a repository
GitRefNameA branch, tag, SHA, or revision expression
GitRemoteNameRemote name
GitRepositoryNameRepository name
GitRepositoryRemotePathClone path or URL
GitRepositoryWebURIRepository web address
AzureDevOpsProjectNameAzure DevOps project name — scopes AzureDevOpsProvider repository enumeration, and required for its pull request operations
GitPullRequestNumberPull request's host-assigned number
GitPullRequestTitlePull request title
GitPullRequestAuthorHost's identifier for the account that opened a pull request (a GitHub login, or an Azure DevOps unique name)
GitPullRequestWebURIPull request web address

Contributing

Contributions are welcome! Feel free to open issues or submit pull requests.

License

This project is licensed under the MIT License. See the LICENSE.md file for details.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ktsu.GitIntegration

A .NET library that wraps the git binary behind a fluent, strongly-typed interface, and unifies access to hosted Git providers behind a single abstraction.

LicenseNuGet VersionNuGet VersionNuGet DownloadsGitHub commit activityGitHub contributorsGitHub Actions Workflow Status

Introduction

ktsu.GitIntegration is a two-layer library. The local layer wraps the git executable found on PATH behind a fluent, strongly-typed interface: open or discover a repository, then build and run both read-only commands (status, log, diff, branches, remotes, rev-parse) and mutating commands (init, clone, add, commit, branch creation and deletion, checkout, remote management, and remote sync via fetch, pull, and push) without shelling out or hand-parsing porcelain output yourself. The hosting layer — the original half of this library — unifies access to hosted Git providers behind a GitProvider abstraction: GitHubProvider, built on Octokit, and AzureDevOpsProvider, built on a raw HttpClient against Azure DevOps's REST API. Both enumerate repositories and list and create pull requests through the same IGitHostingProvider contract.

Every value that would otherwise be a bare string — a branch name, a commit SHA, a remote name, an author email — is instead a validated semantic type built on ktsu.Semantics, so a GitBranchName can no longer be accidentally passed where a GitCommitSha is expected.

Azure DevOps hosting deliberately does not use Microsoft.TeamFoundationServer.Client or the other official TFS/Azure DevOps client package — both pull in System.Data.SqlClient, which carries a known high-severity advisory, as a direct dependency of the published package. AzureDevOpsProvider builds and parses its requests by hand instead.

Features

  • Local Git Client: IGitClient/GitClient finds and opens repositories — GetVersionAsync, IsRepositoryAsync, OpenAsync, DiscoverAsync — and creates new ones — Init(...), Clone(...) — by delegating every invocation to ktsu.RunCommand.
  • Fluent Verb Builders: GitRepository exposes one builder per read-only verb — Status(), Log(), Diff(), Branches(), Remotes(), RevParse(...) — and one per mutating verb — Add(), Commit(...), CreateBranch(...), DeleteBranch(...), Checkout(...), AddRemote(...), RemoveRemote(...), SetRemoteUrl(...), Fetch(), Pull(), Push() — each configurable via chained method calls and run with ExecuteAsync or the non-throwing TryExecuteAsync.
  • Remote Sync: Fetch() downloads objects and refs without touching the working tree, Pull() fetches and integrates into the current branch, and Push() sends local commits — fetch and push report a machine-readable, per-reference account of what happened via GitFetchResult/GitPushResult, and a rejected push is the one place in this library where ExecuteAsync and TryExecuteAsync diverge in more than exception-versus-result.
  • Strongly-Typed Results: GitStatus, GitCommit, GitBranch, GitRemote, GitDiffEntry, GitVersion, GitInitResult, GitCompleted, GitFetchResult, GitPushResult, and GitRefUpdate records replace ad-hoc porcelain parsing with typed models — GitCompleted is the shared result for mutating verbs whose only outcome is success.
  • Reproducible Failures: every command is scoped with git -C <path> instead of a process working directory, so a failing invocation's exact argument vector can be read off a GitCommandException and rerun verbatim.
  • Locale-Safe Parsing: every invocation runs with GIT_TERMINAL_PROMPT=0 (no hanging credential prompts) and LC_ALL=C (English, machine-stable output), which is what makes the output parsers safe to write against fixed English text.
  • Dependency Injection: AddGitIntegration() registers the client, process runner, and options as singletons in one call. The hosting layer is constructed directly instead — see Working with a Hosting Provider.
  • Hosting Provider Abstraction: IGitHostingProvider defines a common contract for enumerating repositories, listing open pull requests, and creating a pull request — GitHubProvider implements it on top of Octokit, AzureDevOpsProvider on a raw HttpClient against Azure DevOps's REST API.
  • Credential Resolution: hosting providers integrate with ktsu.CredentialCache, so credentials come from the host's native keyring rather than configuration files.
  • Semantic Git Types: validated wrapper types for every identifier Git tooling passes around, so mismatched arguments fail at compile time rather than at runtime.

Installation

Package Manager Console

Install-Package ktsu.GitIntegration

.NET CLI

dotnet add package ktsu.GitIntegration

Package Reference

<PackageReferenceInclude="ktsu.GitIntegration"Version="x.y.z" />

Usage Examples

Basic Example

Register the library with dependency injection, then resolve IGitClient:

usingktsu.GitIntegration;usingMicrosoft.Extensions.DependencyInjection;ServiceCollectionservices=new();services.AddGitIntegration();usingServiceProviderprovider=services.BuildServiceProvider();IGitClientclient=provider.GetRequiredService<IGitClient>();

Discovering a Repository and Reading Its Status

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathhere=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepository?repository=awaitclient.DiscoverAsync(here);if(repositoryis not null){GitStatusstatus=awaitrepository.Status().ExecuteAsync();Console.WriteLine(status.IsClean?"Working tree is clean.":$"{status.Entries.Count} changed path(s) on {status.Branch?.WeakString}.");}

Listing Commits and Diffs

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IReadOnlyList<GitCommit>commits=awaitrepository.Log().Take(10).FirstParentOnly().ExecuteAsync();foreach(GitCommitcommitincommits){Console.WriteLine($"{commit.Sha.WeakString[..7]}{commit.Subject}");}IReadOnlyList<GitDiffEntry>changes=awaitrepository.Diff().Staged().DetectRenames().ExecuteAsync();

Initializing or Cloning a Repository

Init probes the target path before running git init, so GitInitResult.AlreadyExisted can tell a caller whether a repository was already there — git init is idempotent and silently ignores --initial-branch when re-initialising, so a caller that asked for a particular initial branch and got AlreadyExisted == true did not get the branch it asked for:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathtarget=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitInitResultinit=awaitclient.Init(target).WithInitialBranch("main".As<GitBranchName>()).ExecuteAsync();GitRepositoryrepository=init.Repository;

Clone builds git clone. Its destination pre-check is advisory only — git enforces the same rule itself, so the check exists solely to fail a doomed clone before it pays its network cost, and it is deliberately racy:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;GitRepositoryRemotePathsource="https://github.com/ktsu-dev/GitIntegration.git".As<GitRepositoryRemotePath>();AbsoluteDirectoryPathdestination=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepositorycloned=awaitclient.Clone(source,destination).WithDepth(1).ReportingProgress(newProgress<string>(line =>Console.WriteLine(line))).ExecuteAsync();

Staging and Committing Changes

Commit runs git twice: git commit itself, then git log -1 with this library's pinned format, because commit's own output is a human summary carrying only an abbreviated object id, with no machine-readable alternative:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.Add().All().ExecuteAsync();GitCommitcommit=awaitrepository.Commit("Add feature X".As<GitCommitMessage>()).WithBody("Longer explanation of the change.").WithAuthor("Ada Lovelace".As<GitAuthorName>(),"ada@example.com".As<GitAuthorEmail>()).ExecuteAsync();Console.WriteLine(commit.Sha.WeakString);

Committing with nothing staged throws GitNothingToCommitException, a GitCommandException specialization, rather than the generic base type — the one commit failure that is an ordinary program state rather than a fault:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Commit("Nothing changed".As<GitCommitMessage>()).ExecuteAsync();}catch(GitNothingToCommitException){Console.WriteLine("Nothing was staged; skipping this commit.");}

Creating Branches and Switching

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.CreateBranch("feature/new-thing".As<GitBranchName>()).StartingAt("main".As<GitRefName>()).ExecuteAsync();awaitrepository.Checkout("feature/new-thing".As<GitRefName>()).ExecuteAsync();// Later, once the branch is no longer needed:awaitrepository.DeleteBranch("feature/new-thing".As<GitBranchName>()).Force().ExecuteAsync();

Managing Remotes

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryRemotePathurl="https://github.com/example/repo.git".As<GitRepositoryRemotePath>();awaitrepository.AddRemote("upstream".As<GitRemoteName>(),url).WithFetch().ExecuteAsync();awaitrepository.SetRemoteUrl("upstream".As<GitRemoteName>(),url).ForPushOnly().ExecuteAsync();awaitrepository.RemoveRemote("upstream".As<GitRemoteName>()).ExecuteAsync();

Fetching

fetch --porcelain is only available from git 2.41 onward, so Fetch() probes the installed git's version first. Below that threshold the fetch still runs and still succeeds, but GitFetchResult.DetailAvailable is false and Updates is empty — check DetailAvailable before trusting an empty Updates as "nothing changed":

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitFetchResultfetched=awaitrepository.Fetch().FromRemote("origin".As<GitRemoteName>()).Prune().WithTags().ExecuteAsync();if(fetched.DetailAvailable){Console.WriteLine(fetched.IsUpToDate?"Already up to date.":$"{fetched.Updates.Count} reference(s) updated.");}else{Console.WriteLine("Fetch completed, but this git is older than 2.41 so no per-reference detail is available.");}

Pulling

Pull() returns GitCompleted rather than a parsed result, because everything git pull prints is human prose with no porcelain form. Use Status() and Log() afterwards to learn what changed. A merge conflict is the one outcome with its own exception, GitPullConflictException, because it leaves the repository mid-merge:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Pull().FromRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).FastForwardOnly().ExecuteAsync();}catch(GitPullConflictException){GitStatusstatus=awaitrepository.Status().ExecuteAsync();IEnumerable<GitStatusEntry>unmerged=status.Entries.Where(e =>e.IndexState==GitFileState.Unmerged);Console.WriteLine($"Pull left {unmerged.Count()} unmerged path(s); resolve them and commit.");}

FastForwardOnly() and Rebase() are mutually exclusive — combining them throws InvalidOperationException when the argument vector is built, since they mean opposite things about history.

Pushing — Why ExecuteAsync and TryExecuteAsync Disagree

push is the one verb in this library where the two entry points mean genuinely different things, not just exception-versus-result. A rejected push exits non-zero from git and prints a complete porcelain record of every reference — git got far enough to talk about them and refused some. A caller who does not know this will get it wrong by assuming TryExecuteAsync returning Success means the push landed:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;// ExecuteAsync stays strict: a rejection throws, and the exception carries the full parsed result.try{GitPushResultpushed=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).SettingUpstream().ExecuteAsync();}catch(GitPushRejectedExceptionex){// ex.Result is the same GitPushResult a successful push would have returned.foreach(GitRefUpdateupdateinex.Result!.Updates.Where(u =>u.IsRejected)){Console.WriteLine($"{update.Reference.WeakString}: {update.Summary}");}}

TryExecuteAsync does not throw for a rejection — git ran and reported exactly what happened, so that report comes back as a successful GitResult. Always check HasRejections, not just Success:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitPushResult>result=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).TryExecuteAsync();if(result.Success&&result.Value!.HasRejections){// This is still result.Success == true: TryExecuteAsync only fails when git never reached// the remote at all. A rejection is reported as data, not as GitResult failure.Console.WriteLine("Push ran but at least one reference was rejected — check result.Value.Updates.");}

ForceWithLease() wins over Force() when both are set, being the safer of the two — it refuses if the remote moved since it was last fetched.

Resolving a Revision Without Throwing

TryExecuteAsync reports a non-zero exit as a result instead of an exception — useful when "no such revision" is an expected outcome rather than a failure:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitCommitSha>result=awaitrepository.RevParse("maybe-missing-branch".As<GitRefName>()).TryExecuteAsync();if(result.Success){Console.WriteLine(result.Value!.WeakString);}else{Console.WriteLine($"git exited {result.Error!.ExitCode}: {result.Error.StandardError}");}

Reproducing a Failing Command

ExecuteAsync throws GitCommandException on a non-zero exit, carrying the exact argument vector git was invoked with:

try{awaitrepository.RevParse("no-such-ref".As<GitRefName>()).ExecuteAsync();}catch(GitCommandExceptionex){// ex.Arguments already begins with "-C <path>", so this can be pasted straight after `git`// on a command line to reproduce the failure exactly.Console.WriteLine("git "+string.Join(' ',ex.Arguments));}

Working with a Hosting Provider

Providers are constructed directly rather than resolved from DI: GitHubProvider and AzureDevOpsProvider need only a caller-supplied Owner (and, for Azure DevOps, an optional Project), so there is nothing a container would meaningfully wire up.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProvidergithub=newGitHubProvider{Owner="ktsu-dev".As<GitProviderOwner>(),};// Credentials come from ktsu.CredentialCache, keyed by PersonaGUID — nothing to configure here// unless a specific persona is needed.IReadOnlyList<GitRepository>repositories=awaitgithub.GetRepositoriesAsync();

GitHubProvider.GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones. Azure DevOps has no equivalent restriction: it returns everything the resolved credential can see.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProviderazure=newAzureDevOpsProvider{Owner="my-org".As<GitProviderOwner>(),Project="my-project".As<AzureDevOpsProjectName>(),};IReadOnlyList<GitRepository>repositories=awaitazure.GetRepositoriesAsync();

Project is only required for pull request operations — Azure DevOps has no project-less pull request endpoint, and calling GetPullRequestsAsync or CreatePullRequest without it throws InvalidOperationException immediately. GetPullRequestsAsync returns open pull requests only, on both hosts:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryNamerepositoryName="GitIntegration".As<GitRepositoryName>();IReadOnlyList<GitPullRequest>openPullRequests=awaitazure.GetPullRequestsAsync(repositoryName);foreach(GitPullRequestpullRequestinopenPullRequests){Console.WriteLine($"#{pullRequest.Number.WeakString}{pullRequest.Title.WeakString} ({pullRequest.State})");}

Creating a pull request goes through a builder, the same idiom as the local layer's mutating verbs:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitPullRequestcreated=awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).Describing("Longer explanation of the change.").ExecuteAsync();Console.WriteLine(created.WebURI?.WeakString);

A hosting failure — authentication, not found, rate limiting, or anything else the host reports — surfaces as a GitHostingException subtype carrying the provider name, HTTP status, and response body, mirroring how GitCommandException carries the argument vector for the local layer:

try{awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).ExecuteAsync();}catch(GitHostingAuthenticationExceptionex){Console.WriteLine($"{ex.ProviderName} rejected the request: {ex.StatusCode}");}catch(GitHostingRateLimitExceptionex){Console.WriteLine($"Rate limited until {ex.ResetsAt}.");}

Working with Semantic Types

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitBranchNamebranch="main".As<GitBranchName>();GitRemoteNameremote="origin".As<GitRemoteName>();GitCommitShasha="9fceb02d0ae598e95dc970b74767f19372d61af8".As<GitCommitSha>();// These are distinct types — passing a GitBranchName where a GitCommitSha// is expected is a compile error, not a runtime surprise.

Advanced Usage

Argument Vectors Are Inspectable Before They Run

Every builder's BuildArguments() is a pure computation with no I/O, so the exact command can be asserted or logged before it executes:

IReadOnlyList<string>arguments=repository.Status().BuildArguments();// ["-C", "<path>", "--no-pager", "-c", "core.quotepath=false", "-c", "color.ui=false",// "status", "--porcelain=v2", "--branch", "-z"]

Metadata-Only Repositories

A GitRepository produced by a hosting provider (rather than IGitClient.OpenAsync or DiscoverAsync) carries hosting metadata but no ProcessRunner. Calling any verb on it throws InvalidOperationException immediately, rather than failing later inside git:

GitRepositorymetadataOnly=new(){LocalPath=somePath,Name="GitIntegration".As<GitRepositoryName>()};// Throws InvalidOperationException — obtain a runnable repository from IGitClient first._=metadataOnly.Status();

API Reference

IGitClient / GitClient

The entry point to the local layer: finds and opens repositories, and reports on the git binary.

Methods

NameReturn TypeDescription
GetVersionAsync(CancellationToken)Task<GitVersion>Reports the version of the git binary being invoked.
IsRepositoryAsync(AbsoluteDirectoryPath, CancellationToken)Task<bool>Decides whether a path is inside a git working tree. Never throws for a non-repository path.
OpenAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository>Opens the repository containing a path. Throws GitRepositoryNotFoundException when there is none.
DiscoverAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository?>Opens the repository containing a path, returning null instead of throwing when there is none.
Init(AbsoluteDirectoryPath)IGitInitBuilderCreates a repository at a path. Probes first, so the result's AlreadyExisted can tell a caller whether one was already there.
Clone(GitRepositoryRemotePath, AbsoluteDirectoryPath)IGitCloneBuilderClones a repository into a local working copy.
Clone(GitRepository)IGitCloneBuilderClones the repository a hosting provider described, using its RemotePath and intended LocalPath.

GitRepository

Carries LocalPath plus optional hosting metadata, and exposes one builder factory per verb.

Properties

NameTypeDescription
LocalPathAbsoluteDirectoryPathThe working tree's local filesystem path.
NameGitRepositoryName?The repository name, when known.
WebURIGitRepositoryWebURI?The browser-facing URI, when known.
RemotePathGitRepositoryRemotePath?The remote clone path, when known.
ProcessRunnerIGitProcessRunner?The runner this repository's verbs execute through; null on a metadata-only repository.

Methods

NameReturn TypeDescription
Status()IGitStatusBuilderBuilds git status --porcelain=v2 --branch -z.
Log()IGitLogBuilderBuilds git log -z with this library's pinned format.
Diff()IGitDiffBuilderBuilds git diff --name-status -z.
Branches()IGitBranchListBuilderBuilds git for-each-ref over the branch namespaces.
Remotes()IGitRemoteListBuilderBuilds git remote -v.
RevParse(GitRefName)IGitRevParseBuilderBuilds git rev-parse --verify for a revision.
Add()IGitAddBuilderBuilds git add.
Commit(GitCommitMessage)IGitCommitBuilderBuilds git commit, then reads the new commit back with git log -1.
CreateBranch(GitBranchName)IGitBranchCreateBuilderBuilds git branch <name> [<start-point>].
DeleteBranch(GitBranchName)IGitBranchDeleteBuilderBuilds git branch --delete <name>.
Checkout(GitRefName)IGitCheckoutBuilderBuilds git checkout.
AddRemote(GitRemoteName, GitRepositoryRemotePath)IGitRemoteAddBuilderBuilds git remote add <name> <url>.
RemoveRemote(GitRemoteName)IGitRemoteRemoveBuilderBuilds git remote remove <name>.
SetRemoteUrl(GitRemoteName, GitRepositoryRemotePath)IGitRemoteSetUrlBuilderBuilds git remote set-url <name> <url>.
Fetch()IGitFetchBuilderBuilds git fetch, with --porcelain where the installed git supports it.
Pull()IGitPullBuilderBuilds git pull.
Push()IGitPushBuilderBuilds git push --porcelain.
IsClonedAsync(CancellationToken)Task<bool>Decides whether LocalPath currently holds a git working tree.
OpenWebClient()voidOpens WebURI in the default browser, when it is an absolute http/https URI.

IGitCommandBuilder<TResult>

The shared contract every verb builder implements. A builder is single-use and not thread-safe.

Methods

NameReturn TypeDescription
BuildArguments()IReadOnlyList<string>The exact argument vector this builder will pass to git. Pure, no I/O.
ExecuteAsync(CancellationToken)Task<TResult>Runs the command, throwing GitCommandException when git exits non-zero.
TryExecuteAsync(CancellationToken)Task<GitResult<TResult>>Runs the command, reporting a non-zero exit as a result instead of throwing.

Verb Builders

InterfaceExtra MethodsResult
IGitStatusBuilderWithUntrackedFiles(GitUntrackedFilesMode), IncludeIgnored()GitStatus
IGitLogBuilderTake(int), Skip(int), ForRevision(GitRefName), ForPath(RelativeFilePath), FirstParentOnly()IReadOnlyList<GitCommit>
IGitDiffBuilderStaged(), Against(GitRefName), Between(GitRefName, GitRefName), DetectRenames(), DetectCopies(), ForPath(RelativeFilePath)IReadOnlyList<GitDiffEntry>
IGitBranchListBuilderLocalOnly(), RemoteOnly()IReadOnlyList<GitBranch>
IGitRemoteListBuilder(none)IReadOnlyList<GitRemote>
IGitRevParseBuilder(none — revision supplied via GitRepository.RevParse)GitCommitSha
IGitInitBuilderBare(), WithInitialBranch(GitBranchName)GitInitResult
IGitCloneBuilderWithBranch(GitBranchName), WithDepth(int), Bare(), ReportingProgress(IProgress<string>)GitRepository
IGitAddBuilderForPath(RelativeFilePath), All(), UpdateTrackedOnly()GitCompleted
IGitCommitBuilderWithBody(string), AllowEmpty(), StageTrackedFiles(), WithAuthor(GitAuthorName, GitAuthorEmail)GitCommit
IGitBranchCreateBuilderStartingAt(GitRefName), Force()GitCompleted
IGitBranchDeleteBuilderForce()GitCompleted
IGitCheckoutBuilderCreatingBranch(), Force(), Detach()GitCompleted
IGitRemoteAddBuilderWithFetch()GitCompleted
IGitRemoteRemoveBuilder(none)GitCompleted
IGitRemoteSetUrlBuilderForPushOnly()GitCompleted
IGitFetchBuilderFromRemote(GitRemoteName), AllRemotes(), Prune(), WithTags(), WithDepth(int), ReportingProgress(IProgress<string>)GitFetchResult
IGitPullBuilderFromRemote(GitRemoteName), WithBranch(GitBranchName), FastForwardOnly(), Rebase(), Prune(), ReportingProgress(IProgress<string>)GitCompleted
IGitPushBuilderToRemote(GitRemoteName), WithBranch(GitBranchName), SettingUpstream(), Force(), ForceWithLease(), DeletingRemoteBranch(), DryRun(), ReportingProgress(IProgress<string>)GitPushResult

Result and Execution Models

TypeDescription
GitOptionsConfigures the git executable path and a per-invocation timeout.
IGitProcessRunnerRuns the git executable with a given argument vector; implemented by RunCommandGitProcessRunner.
GitResult<T>The outcome of a command run with TryExecuteAsync: either Value or Error, never both.
GitCommandErrorThe exit code, argument vector, and standard error of a failed invocation.

Exceptions

TypeThrown When
GitExceptionBase type for every failure originating in this library.
GitExecutableNotFoundExceptionThe git executable could not be started.
GitTimeoutExceptionGit did not complete within the configured GitOptions.Timeout.
GitParseExceptionGit succeeded but produced output the parser could not interpret.
GitCommandExceptionGit ran and exited non-zero. Carries ExitCode, Arguments, and StandardError.
GitRepositoryNotFoundExceptionA GitCommandException specialization: the path is not inside a git working tree.
GitNothingToCommitExceptionA GitCommandException specialization: git commit was run with nothing staged. The one commit failure that is an ordinary program state rather than a fault.
GitPushRejectedExceptionA GitCommandException specialization: git refused at least one reference during push. Carries the parsed Result (GitPushResult) so the rejection detail is not lost.
GitPullConflictExceptionA GitCommandException specialization: pull left conflicts in the working tree. Use Status() to see which paths are unmerged.
GitHostingExceptionBase type for every hosting-layer failure. Does not derive from GitException — it carries HTTP concepts, not process ones. Carries ProviderName, StatusCode, ResponseBody.
GitHostingAuthenticationExceptionA GitHostingException specialization: the host rejected the request as unauthenticated, or the credential no longer grants access.
GitHostingNotFoundExceptionA GitHostingException specialization: the requested resource does not exist, or is not visible to the caller's credentials.
GitHostingRateLimitExceptionA GitHostingException specialization: the caller has exhausted its request quota. Carries ResetsAt, when the host reported one.
GitHostingRequestExceptionA GitHostingException specialization for any other non-success response — a malformed request, a validation failure, or a server-side error.

Result Models

TypeDescription
GitStatusBranch, Upstream, Ahead, Behind, IsDetached, Entries, IsClean.
GitStatusEntryIndexState, WorkTreeState, Path, OriginalPath for one changed path.
GitCommitSha, TreeSha, ParentShas, Author, Committer, Subject, Body.
GitSignatureName, Email, Timestamp recorded on a commit.
GitBranchName, Sha, Upstream, IsCurrent, IsRemote.
GitRemoteName, FetchUrl, PushUrl.
GitDiffEntryKind, Path, OriginalPath, SimilarityPercent.
GitVersionMajor, Minor, Patch, Raw, plus AtLeast(major, minor).
GitFileStateEnum: Unmodified, Modified, Added, Deleted, Renamed, Copied, Untracked, Ignored, Unmerged, TypeChanged.
GitChangeKindEnum: Added, Copied, Deleted, Modified, Renamed, TypeChanged, Unmerged, Unknown.
GitUntrackedFilesModeEnum: No, Normal, All.
GitCompletedThe result of a mutating verb whose only outcome is success — add, checkout, branch creation/deletion, remote commands, and pull. Carries Arguments.
GitInitResultRepository, AlreadyExisted — the outcome of IGitClient.Init.
GitFetchResultUpdates, DetailAvailable, IsUpToDate — the outcome of Fetch(). IsUpToDate is gated on DetailAvailable so an empty Updates from a pre-2.41 git is never mistaken for "nothing changed".
GitPushResultUpdates, HasRejections — the outcome of Push().
GitRefUpdateKind, Reference, Source, OldSha, NewSha, Summary, IsRejected — one reference changed by a fetch or a push.
GitRefUpdateKindEnum: FastForward, Forced, Removed, Created, Rejected, UpToDate, TagUpdate, Unknown.
GitPullRequestNumber, Title, Description, SourceBranch, TargetBranch, Author, State, IsDraft, WebURI, CreatedAt — one pull request, as reported by a hosting provider. A null optional field means the host did not report that value.
GitPullRequestStateEnum: Open, Merged, Closed.

IGitHostingProvider

The contract every hosting provider implements: repository enumeration, pull request listing, and pull request creation, over whichever transport and authentication scheme the host requires.

Properties

NameTypeDescription
NameGitProviderNameDisplay name of the provider.
OwnerGitProviderOwnerThe owner of the repositories in this provider.
PersonaGUIDPersonaGUIDThe persona GUID used for authentication with the provider (from ktsu.CredentialCache).
IsAuthenticatedboolWhether requests this provider issues carry a credential. A cache entry that resolves to "proceed unauthenticated", and one of a type the provider cannot apply, both report false.

Methods

NameReturn TypeDescription
GetRepositoriesAsync(CancellationToken)Task<IReadOnlyList<GitRepository>>Retrieves the repositories Owner has, from the host. Coverage differs by host — see GitHubProvider and AzureDevOpsProvider below.
GetPullRequestsAsync(GitRepositoryName, CancellationToken)Task<IReadOnlyList<GitPullRequest>>Retrieves a repository's open pull requests. The filter is requested explicitly of the host, not left to its default.
CreatePullRequest(GitRepositoryName)IGitPullRequestCreateBuilderStarts building a pull request for a repository.

GitProvider

The abstract base both hosting providers derive from. Implements IGitHostingProvider and resolves credentials via TryGetCredential/ResolveCredential; each concrete provider builds its own HttpClient-based transport around that credential — see GitHubProvider and AzureDevOpsProvider below for how the two differ. public bool TryGetCredential(out Credential?) is declared here, not on IGitHostingProvider, and reports only whether the credential cache holds an entry for this provider's PersonaGUID. IsAuthenticated is the narrower question of whether a request would actually carry one.

Each provider type keeps one shared SocketsHttpHandler for the life of the process and builds a short-lived client or adapter over it per call, so a caller looping over many repositories reuses one connection pool rather than building and tearing down one per request. Neither provider is IDisposable, and neither ever disposes a handler injected for testing.

GitHubProvider

GitProvider implementation backed by Octokit. GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones, and supplying a token does not widen this.

AzureDevOpsProvider

GitProvider implementation built on a raw HttpClient against Azure DevOps's REST API (api-version=7.1) — no Azure DevOps client library is referenced (see the Introduction). GetPullRequestsAsync pages with $top and $skip until a short page comes back, so a repository with more open pull requests than one page costs more than one request and is never truncated. GetRepositoriesAsync issues exactly one request, because that endpoint documents no pagination.

Additional Properties

NameTypeDescription
ProjectAzureDevOpsProjectName?Scopes repository enumeration to a project, or null to enumerate the whole organisation. Required for GetPullRequestsAsync and CreatePullRequest — Azure DevOps has no project-less pull request endpoint, and calling either without it throws InvalidOperationException.

GetRepositoriesAsync returns everything the resolved credential can see — unlike GitHubProvider, there is no public-only restriction.

IGitPullRequestCreateBuilder

Collects a pull request's details before submitting it. From, Into, and Titled are required; Describing and AsDraft are optional. A missing required value throws InvalidOperationException from ExecuteAsync, not from the setter that left it unset, since parts may be supplied in any order.

NameReturn TypeDescription
From(GitBranchName)IGitPullRequestCreateBuilderSets the source branch.
Into(GitBranchName)IGitPullRequestCreateBuilderSets the target branch.
Titled(GitPullRequestTitle)IGitPullRequestCreateBuilderSets the title.
Describing(string)IGitPullRequestCreateBuilderSets the description.
AsDraft()IGitPullRequestCreateBuilderMarks the pull request as a draft.
ExecuteAsync(CancellationToken)Task<GitPullRequest>Submits the pull request to the host.

ServiceCollectionExtensions

NameReturn TypeDescription
AddGitIntegration(IServiceCollection)IServiceCollectionRegisters git integration with default options, invoking the git found on PATH.
AddGitIntegration(IServiceCollection, Action<GitOptions>)IServiceCollectionRegisters git integration with configured options. Idempotent per service.

Registers only the local layer. Hosting providers are constructed directly — see Working with a Hosting Provider — since neither GitHubProvider nor AzureDevOpsProvider has a constructor dependency a container could supply.

Semantic Types

TypeWraps
GitAuthorEmailCommit author or committer email address
GitAuthorNameCommit author or committer name
GitBranchNameBranch name
GitCommitMessageCommit message
GitCommitShaCommit object id (abbreviated or full, including SHA-256 repositories)
GitProviderNameHosting provider display name
GitProviderOwnerAccount or organization owning a repository
GitRefNameA branch, tag, SHA, or revision expression
GitRemoteNameRemote name
GitRepositoryNameRepository name
GitRepositoryRemotePathClone path or URL
GitRepositoryWebURIRepository web address
AzureDevOpsProjectNameAzure DevOps project name — scopes AzureDevOpsProvider repository enumeration, and required for its pull request operations
GitPullRequestNumberPull request's host-assigned number
GitPullRequestTitlePull request title
GitPullRequestAuthorHost's identifier for the account that opened a pull request (a GitHub login, or an Azure DevOps unique name)
GitPullRequestWebURIPull request web address

Contributing

Contributions are welcome! Feel free to open issues or submit pull requests.

License

This project is licensed under the MIT License. See the LICENSE.md file for details.

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

ktsu.GitIntegration

A .NET library that wraps the git binary behind a fluent, strongly-typed interface, and unifies access to hosted Git providers behind a single abstraction.

LicenseNuGet VersionNuGet VersionNuGet DownloadsGitHub commit activityGitHub contributorsGitHub Actions Workflow Status

Introduction

ktsu.GitIntegration is a two-layer library. The local layer wraps the git executable found on PATH behind a fluent, strongly-typed interface: open or discover a repository, then build and run both read-only commands (status, log, diff, branches, remotes, rev-parse) and mutating commands (init, clone, add, commit, branch creation and deletion, checkout, remote management, and remote sync via fetch, pull, and push) without shelling out or hand-parsing porcelain output yourself. The hosting layer — the original half of this library — unifies access to hosted Git providers behind a GitProvider abstraction: GitHubProvider, built on Octokit, and AzureDevOpsProvider, built on a raw HttpClient against Azure DevOps's REST API. Both enumerate repositories and list and create pull requests through the same IGitHostingProvider contract.

Every value that would otherwise be a bare string — a branch name, a commit SHA, a remote name, an author email — is instead a validated semantic type built on ktsu.Semantics, so a GitBranchName can no longer be accidentally passed where a GitCommitSha is expected.

Azure DevOps hosting deliberately does not use Microsoft.TeamFoundationServer.Client or the other official TFS/Azure DevOps client package — both pull in System.Data.SqlClient, which carries a known high-severity advisory, as a direct dependency of the published package. AzureDevOpsProvider builds and parses its requests by hand instead.

Features

  • Local Git Client: IGitClient/GitClient finds and opens repositories — GetVersionAsync, IsRepositoryAsync, OpenAsync, DiscoverAsync — and creates new ones — Init(...), Clone(...) — by delegating every invocation to ktsu.RunCommand.
  • Fluent Verb Builders: GitRepository exposes one builder per read-only verb — Status(), Log(), Diff(), Branches(), Remotes(), RevParse(...) — and one per mutating verb — Add(), Commit(...), CreateBranch(...), DeleteBranch(...), Checkout(...), AddRemote(...), RemoveRemote(...), SetRemoteUrl(...), Fetch(), Pull(), Push() — each configurable via chained method calls and run with ExecuteAsync or the non-throwing TryExecuteAsync.
  • Remote Sync: Fetch() downloads objects and refs without touching the working tree, Pull() fetches and integrates into the current branch, and Push() sends local commits — fetch and push report a machine-readable, per-reference account of what happened via GitFetchResult/GitPushResult, and a rejected push is the one place in this library where ExecuteAsync and TryExecuteAsync diverge in more than exception-versus-result.
  • Strongly-Typed Results: GitStatus, GitCommit, GitBranch, GitRemote, GitDiffEntry, GitVersion, GitInitResult, GitCompleted, GitFetchResult, GitPushResult, and GitRefUpdate records replace ad-hoc porcelain parsing with typed models — GitCompleted is the shared result for mutating verbs whose only outcome is success.
  • Reproducible Failures: every command is scoped with git -C <path> instead of a process working directory, so a failing invocation's exact argument vector can be read off a GitCommandException and rerun verbatim.
  • Locale-Safe Parsing: every invocation runs with GIT_TERMINAL_PROMPT=0 (no hanging credential prompts) and LC_ALL=C (English, machine-stable output), which is what makes the output parsers safe to write against fixed English text.
  • Dependency Injection: AddGitIntegration() registers the client, process runner, and options as singletons in one call. The hosting layer is constructed directly instead — see Working with a Hosting Provider.
  • Hosting Provider Abstraction: IGitHostingProvider defines a common contract for enumerating repositories, listing open pull requests, and creating a pull request — GitHubProvider implements it on top of Octokit, AzureDevOpsProvider on a raw HttpClient against Azure DevOps's REST API.
  • Credential Resolution: hosting providers integrate with ktsu.CredentialCache, so credentials come from the host's native keyring rather than configuration files.
  • Semantic Git Types: validated wrapper types for every identifier Git tooling passes around, so mismatched arguments fail at compile time rather than at runtime.

Installation

Package Manager Console

Install-Package ktsu.GitIntegration

.NET CLI

dotnet add package ktsu.GitIntegration

Package Reference

<PackageReferenceInclude="ktsu.GitIntegration"Version="x.y.z" />

Usage Examples

Basic Example

Register the library with dependency injection, then resolve IGitClient:

usingktsu.GitIntegration;usingMicrosoft.Extensions.DependencyInjection;ServiceCollectionservices=new();services.AddGitIntegration();usingServiceProviderprovider=services.BuildServiceProvider();IGitClientclient=provider.GetRequiredService<IGitClient>();

Discovering a Repository and Reading Its Status

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathhere=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepository?repository=awaitclient.DiscoverAsync(here);if(repositoryis not null){GitStatusstatus=awaitrepository.Status().ExecuteAsync();Console.WriteLine(status.IsClean?"Working tree is clean.":$"{status.Entries.Count} changed path(s) on {status.Branch?.WeakString}.");}

Listing Commits and Diffs

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IReadOnlyList<GitCommit>commits=awaitrepository.Log().Take(10).FirstParentOnly().ExecuteAsync();foreach(GitCommitcommitincommits){Console.WriteLine($"{commit.Sha.WeakString[..7]}{commit.Subject}");}IReadOnlyList<GitDiffEntry>changes=awaitrepository.Diff().Staged().DetectRenames().ExecuteAsync();

Initializing or Cloning a Repository

Init probes the target path before running git init, so GitInitResult.AlreadyExisted can tell a caller whether a repository was already there — git init is idempotent and silently ignores --initial-branch when re-initialising, so a caller that asked for a particular initial branch and got AlreadyExisted == true did not get the branch it asked for:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;AbsoluteDirectoryPathtarget=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitInitResultinit=awaitclient.Init(target).WithInitialBranch("main".As<GitBranchName>()).ExecuteAsync();GitRepositoryrepository=init.Repository;

Clone builds git clone. Its destination pre-check is advisory only — git enforces the same rule itself, so the check exists solely to fail a doomed clone before it pays its network cost, and it is deliberately racy:

usingktsu.GitIntegration;usingktsu.Semantics.Paths;usingktsu.Semantics.Strings;GitRepositoryRemotePathsource="https://github.com/ktsu-dev/GitIntegration.git".As<GitRepositoryRemotePath>();AbsoluteDirectoryPathdestination=Environment.CurrentDirectory.As<AbsoluteDirectoryPath>();GitRepositorycloned=awaitclient.Clone(source,destination).WithDepth(1).ReportingProgress(newProgress<string>(line =>Console.WriteLine(line))).ExecuteAsync();

Staging and Committing Changes

Commit runs git twice: git commit itself, then git log -1 with this library's pinned format, because commit's own output is a human summary carrying only an abbreviated object id, with no machine-readable alternative:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.Add().All().ExecuteAsync();GitCommitcommit=awaitrepository.Commit("Add feature X".As<GitCommitMessage>()).WithBody("Longer explanation of the change.").WithAuthor("Ada Lovelace".As<GitAuthorName>(),"ada@example.com".As<GitAuthorEmail>()).ExecuteAsync();Console.WriteLine(commit.Sha.WeakString);

Committing with nothing staged throws GitNothingToCommitException, a GitCommandException specialization, rather than the generic base type — the one commit failure that is an ordinary program state rather than a fault:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Commit("Nothing changed".As<GitCommitMessage>()).ExecuteAsync();}catch(GitNothingToCommitException){Console.WriteLine("Nothing was staged; skipping this commit.");}

Creating Branches and Switching

usingktsu.GitIntegration;usingktsu.Semantics.Strings;awaitrepository.CreateBranch("feature/new-thing".As<GitBranchName>()).StartingAt("main".As<GitRefName>()).ExecuteAsync();awaitrepository.Checkout("feature/new-thing".As<GitRefName>()).ExecuteAsync();// Later, once the branch is no longer needed:awaitrepository.DeleteBranch("feature/new-thing".As<GitBranchName>()).Force().ExecuteAsync();

Managing Remotes

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryRemotePathurl="https://github.com/example/repo.git".As<GitRepositoryRemotePath>();awaitrepository.AddRemote("upstream".As<GitRemoteName>(),url).WithFetch().ExecuteAsync();awaitrepository.SetRemoteUrl("upstream".As<GitRemoteName>(),url).ForPushOnly().ExecuteAsync();awaitrepository.RemoveRemote("upstream".As<GitRemoteName>()).ExecuteAsync();

Fetching

fetch --porcelain is only available from git 2.41 onward, so Fetch() probes the installed git's version first. Below that threshold the fetch still runs and still succeeds, but GitFetchResult.DetailAvailable is false and Updates is empty — check DetailAvailable before trusting an empty Updates as "nothing changed":

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitFetchResultfetched=awaitrepository.Fetch().FromRemote("origin".As<GitRemoteName>()).Prune().WithTags().ExecuteAsync();if(fetched.DetailAvailable){Console.WriteLine(fetched.IsUpToDate?"Already up to date.":$"{fetched.Updates.Count} reference(s) updated.");}else{Console.WriteLine("Fetch completed, but this git is older than 2.41 so no per-reference detail is available.");}

Pulling

Pull() returns GitCompleted rather than a parsed result, because everything git pull prints is human prose with no porcelain form. Use Status() and Log() afterwards to learn what changed. A merge conflict is the one outcome with its own exception, GitPullConflictException, because it leaves the repository mid-merge:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;try{awaitrepository.Pull().FromRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).FastForwardOnly().ExecuteAsync();}catch(GitPullConflictException){GitStatusstatus=awaitrepository.Status().ExecuteAsync();IEnumerable<GitStatusEntry>unmerged=status.Entries.Where(e =>e.IndexState==GitFileState.Unmerged);Console.WriteLine($"Pull left {unmerged.Count()} unmerged path(s); resolve them and commit.");}

FastForwardOnly() and Rebase() are mutually exclusive — combining them throws InvalidOperationException when the argument vector is built, since they mean opposite things about history.

Pushing — Why ExecuteAsync and TryExecuteAsync Disagree

push is the one verb in this library where the two entry points mean genuinely different things, not just exception-versus-result. A rejected push exits non-zero from git and prints a complete porcelain record of every reference — git got far enough to talk about them and refused some. A caller who does not know this will get it wrong by assuming TryExecuteAsync returning Success means the push landed:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;// ExecuteAsync stays strict: a rejection throws, and the exception carries the full parsed result.try{GitPushResultpushed=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).SettingUpstream().ExecuteAsync();}catch(GitPushRejectedExceptionex){// ex.Result is the same GitPushResult a successful push would have returned.foreach(GitRefUpdateupdateinex.Result!.Updates.Where(u =>u.IsRejected)){Console.WriteLine($"{update.Reference.WeakString}: {update.Summary}");}}

TryExecuteAsync does not throw for a rejection — git ran and reported exactly what happened, so that report comes back as a successful GitResult. Always check HasRejections, not just Success:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitPushResult>result=awaitrepository.Push().ToRemote("origin".As<GitRemoteName>()).WithBranch("main".As<GitBranchName>()).TryExecuteAsync();if(result.Success&&result.Value!.HasRejections){// This is still result.Success == true: TryExecuteAsync only fails when git never reached// the remote at all. A rejection is reported as data, not as GitResult failure.Console.WriteLine("Push ran but at least one reference was rejected — check result.Value.Updates.");}

ForceWithLease() wins over Force() when both are set, being the safer of the two — it refuses if the remote moved since it was last fetched.

Resolving a Revision Without Throwing

TryExecuteAsync reports a non-zero exit as a result instead of an exception — useful when "no such revision" is an expected outcome rather than a failure:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitResult<GitCommitSha>result=awaitrepository.RevParse("maybe-missing-branch".As<GitRefName>()).TryExecuteAsync();if(result.Success){Console.WriteLine(result.Value!.WeakString);}else{Console.WriteLine($"git exited {result.Error!.ExitCode}: {result.Error.StandardError}");}

Reproducing a Failing Command

ExecuteAsync throws GitCommandException on a non-zero exit, carrying the exact argument vector git was invoked with:

try{awaitrepository.RevParse("no-such-ref".As<GitRefName>()).ExecuteAsync();}catch(GitCommandExceptionex){// ex.Arguments already begins with "-C <path>", so this can be pasted straight after `git`// on a command line to reproduce the failure exactly.Console.WriteLine("git "+string.Join(' ',ex.Arguments));}

Working with a Hosting Provider

Providers are constructed directly rather than resolved from DI: GitHubProvider and AzureDevOpsProvider need only a caller-supplied Owner (and, for Azure DevOps, an optional Project), so there is nothing a container would meaningfully wire up.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProvidergithub=newGitHubProvider{Owner="ktsu-dev".As<GitProviderOwner>(),};// Credentials come from ktsu.CredentialCache, keyed by PersonaGUID — nothing to configure here// unless a specific persona is needed.IReadOnlyList<GitRepository>repositories=awaitgithub.GetRepositoriesAsync();

GitHubProvider.GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones. Azure DevOps has no equivalent restriction: it returns everything the resolved credential can see.

usingktsu.GitIntegration;usingktsu.Semantics.Strings;IGitHostingProviderazure=newAzureDevOpsProvider{Owner="my-org".As<GitProviderOwner>(),Project="my-project".As<AzureDevOpsProjectName>(),};IReadOnlyList<GitRepository>repositories=awaitazure.GetRepositoriesAsync();

Project is only required for pull request operations — Azure DevOps has no project-less pull request endpoint, and calling GetPullRequestsAsync or CreatePullRequest without it throws InvalidOperationException immediately. GetPullRequestsAsync returns open pull requests only, on both hosts:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitRepositoryNamerepositoryName="GitIntegration".As<GitRepositoryName>();IReadOnlyList<GitPullRequest>openPullRequests=awaitazure.GetPullRequestsAsync(repositoryName);foreach(GitPullRequestpullRequestinopenPullRequests){Console.WriteLine($"#{pullRequest.Number.WeakString}{pullRequest.Title.WeakString} ({pullRequest.State})");}

Creating a pull request goes through a builder, the same idiom as the local layer's mutating verbs:

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitPullRequestcreated=awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).Describing("Longer explanation of the change.").ExecuteAsync();Console.WriteLine(created.WebURI?.WeakString);

A hosting failure — authentication, not found, rate limiting, or anything else the host reports — surfaces as a GitHostingException subtype carrying the provider name, HTTP status, and response body, mirroring how GitCommandException carries the argument vector for the local layer:

try{awaitazure.CreatePullRequest(repositoryName).From("feature/new-thing".As<GitBranchName>()).Into("main".As<GitBranchName>()).Titled("Add feature X".As<GitPullRequestTitle>()).ExecuteAsync();}catch(GitHostingAuthenticationExceptionex){Console.WriteLine($"{ex.ProviderName} rejected the request: {ex.StatusCode}");}catch(GitHostingRateLimitExceptionex){Console.WriteLine($"Rate limited until {ex.ResetsAt}.");}

Working with Semantic Types

usingktsu.GitIntegration;usingktsu.Semantics.Strings;GitBranchNamebranch="main".As<GitBranchName>();GitRemoteNameremote="origin".As<GitRemoteName>();GitCommitShasha="9fceb02d0ae598e95dc970b74767f19372d61af8".As<GitCommitSha>();// These are distinct types — passing a GitBranchName where a GitCommitSha// is expected is a compile error, not a runtime surprise.

Advanced Usage

Argument Vectors Are Inspectable Before They Run

Every builder's BuildArguments() is a pure computation with no I/O, so the exact command can be asserted or logged before it executes:

IReadOnlyList<string>arguments=repository.Status().BuildArguments();// ["-C", "<path>", "--no-pager", "-c", "core.quotepath=false", "-c", "color.ui=false",// "status", "--porcelain=v2", "--branch", "-z"]

Metadata-Only Repositories

A GitRepository produced by a hosting provider (rather than IGitClient.OpenAsync or DiscoverAsync) carries hosting metadata but no ProcessRunner. Calling any verb on it throws InvalidOperationException immediately, rather than failing later inside git:

GitRepositorymetadataOnly=new(){LocalPath=somePath,Name="GitIntegration".As<GitRepositoryName>()};// Throws InvalidOperationException — obtain a runnable repository from IGitClient first._=metadataOnly.Status();

API Reference

IGitClient / GitClient

The entry point to the local layer: finds and opens repositories, and reports on the git binary.

Methods

NameReturn TypeDescription
GetVersionAsync(CancellationToken)Task<GitVersion>Reports the version of the git binary being invoked.
IsRepositoryAsync(AbsoluteDirectoryPath, CancellationToken)Task<bool>Decides whether a path is inside a git working tree. Never throws for a non-repository path.
OpenAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository>Opens the repository containing a path. Throws GitRepositoryNotFoundException when there is none.
DiscoverAsync(AbsoluteDirectoryPath, CancellationToken)Task<GitRepository?>Opens the repository containing a path, returning null instead of throwing when there is none.
Init(AbsoluteDirectoryPath)IGitInitBuilderCreates a repository at a path. Probes first, so the result's AlreadyExisted can tell a caller whether one was already there.
Clone(GitRepositoryRemotePath, AbsoluteDirectoryPath)IGitCloneBuilderClones a repository into a local working copy.
Clone(GitRepository)IGitCloneBuilderClones the repository a hosting provider described, using its RemotePath and intended LocalPath.

GitRepository

Carries LocalPath plus optional hosting metadata, and exposes one builder factory per verb.

Properties

NameTypeDescription
LocalPathAbsoluteDirectoryPathThe working tree's local filesystem path.
NameGitRepositoryName?The repository name, when known.
WebURIGitRepositoryWebURI?The browser-facing URI, when known.
RemotePathGitRepositoryRemotePath?The remote clone path, when known.
ProcessRunnerIGitProcessRunner?The runner this repository's verbs execute through; null on a metadata-only repository.

Methods

NameReturn TypeDescription
Status()IGitStatusBuilderBuilds git status --porcelain=v2 --branch -z.
Log()IGitLogBuilderBuilds git log -z with this library's pinned format.
Diff()IGitDiffBuilderBuilds git diff --name-status -z.
Branches()IGitBranchListBuilderBuilds git for-each-ref over the branch namespaces.
Remotes()IGitRemoteListBuilderBuilds git remote -v.
RevParse(GitRefName)IGitRevParseBuilderBuilds git rev-parse --verify for a revision.
Add()IGitAddBuilderBuilds git add.
Commit(GitCommitMessage)IGitCommitBuilderBuilds git commit, then reads the new commit back with git log -1.
CreateBranch(GitBranchName)IGitBranchCreateBuilderBuilds git branch <name> [<start-point>].
DeleteBranch(GitBranchName)IGitBranchDeleteBuilderBuilds git branch --delete <name>.
Checkout(GitRefName)IGitCheckoutBuilderBuilds git checkout.
AddRemote(GitRemoteName, GitRepositoryRemotePath)IGitRemoteAddBuilderBuilds git remote add <name> <url>.
RemoveRemote(GitRemoteName)IGitRemoteRemoveBuilderBuilds git remote remove <name>.
SetRemoteUrl(GitRemoteName, GitRepositoryRemotePath)IGitRemoteSetUrlBuilderBuilds git remote set-url <name> <url>.
Fetch()IGitFetchBuilderBuilds git fetch, with --porcelain where the installed git supports it.
Pull()IGitPullBuilderBuilds git pull.
Push()IGitPushBuilderBuilds git push --porcelain.
IsClonedAsync(CancellationToken)Task<bool>Decides whether LocalPath currently holds a git working tree.
OpenWebClient()voidOpens WebURI in the default browser, when it is an absolute http/https URI.

IGitCommandBuilder<TResult>

The shared contract every verb builder implements. A builder is single-use and not thread-safe.

Methods

NameReturn TypeDescription
BuildArguments()IReadOnlyList<string>The exact argument vector this builder will pass to git. Pure, no I/O.
ExecuteAsync(CancellationToken)Task<TResult>Runs the command, throwing GitCommandException when git exits non-zero.
TryExecuteAsync(CancellationToken)Task<GitResult<TResult>>Runs the command, reporting a non-zero exit as a result instead of throwing.

Verb Builders

InterfaceExtra MethodsResult
IGitStatusBuilderWithUntrackedFiles(GitUntrackedFilesMode), IncludeIgnored()GitStatus
IGitLogBuilderTake(int), Skip(int), ForRevision(GitRefName), ForPath(RelativeFilePath), FirstParentOnly()IReadOnlyList<GitCommit>
IGitDiffBuilderStaged(), Against(GitRefName), Between(GitRefName, GitRefName), DetectRenames(), DetectCopies(), ForPath(RelativeFilePath)IReadOnlyList<GitDiffEntry>
IGitBranchListBuilderLocalOnly(), RemoteOnly()IReadOnlyList<GitBranch>
IGitRemoteListBuilder(none)IReadOnlyList<GitRemote>
IGitRevParseBuilder(none — revision supplied via GitRepository.RevParse)GitCommitSha
IGitInitBuilderBare(), WithInitialBranch(GitBranchName)GitInitResult
IGitCloneBuilderWithBranch(GitBranchName), WithDepth(int), Bare(), ReportingProgress(IProgress<string>)GitRepository
IGitAddBuilderForPath(RelativeFilePath), All(), UpdateTrackedOnly()GitCompleted
IGitCommitBuilderWithBody(string), AllowEmpty(), StageTrackedFiles(), WithAuthor(GitAuthorName, GitAuthorEmail)GitCommit
IGitBranchCreateBuilderStartingAt(GitRefName), Force()GitCompleted
IGitBranchDeleteBuilderForce()GitCompleted
IGitCheckoutBuilderCreatingBranch(), Force(), Detach()GitCompleted
IGitRemoteAddBuilderWithFetch()GitCompleted
IGitRemoteRemoveBuilder(none)GitCompleted
IGitRemoteSetUrlBuilderForPushOnly()GitCompleted
IGitFetchBuilderFromRemote(GitRemoteName), AllRemotes(), Prune(), WithTags(), WithDepth(int), ReportingProgress(IProgress<string>)GitFetchResult
IGitPullBuilderFromRemote(GitRemoteName), WithBranch(GitBranchName), FastForwardOnly(), Rebase(), Prune(), ReportingProgress(IProgress<string>)GitCompleted
IGitPushBuilderToRemote(GitRemoteName), WithBranch(GitBranchName), SettingUpstream(), Force(), ForceWithLease(), DeletingRemoteBranch(), DryRun(), ReportingProgress(IProgress<string>)GitPushResult

Result and Execution Models

TypeDescription
GitOptionsConfigures the git executable path and a per-invocation timeout.
IGitProcessRunnerRuns the git executable with a given argument vector; implemented by RunCommandGitProcessRunner.
GitResult<T>The outcome of a command run with TryExecuteAsync: either Value or Error, never both.
GitCommandErrorThe exit code, argument vector, and standard error of a failed invocation.

Exceptions

TypeThrown When
GitExceptionBase type for every failure originating in this library.
GitExecutableNotFoundExceptionThe git executable could not be started.
GitTimeoutExceptionGit did not complete within the configured GitOptions.Timeout.
GitParseExceptionGit succeeded but produced output the parser could not interpret.
GitCommandExceptionGit ran and exited non-zero. Carries ExitCode, Arguments, and StandardError.
GitRepositoryNotFoundExceptionA GitCommandException specialization: the path is not inside a git working tree.
GitNothingToCommitExceptionA GitCommandException specialization: git commit was run with nothing staged. The one commit failure that is an ordinary program state rather than a fault.
GitPushRejectedExceptionA GitCommandException specialization: git refused at least one reference during push. Carries the parsed Result (GitPushResult) so the rejection detail is not lost.
GitPullConflictExceptionA GitCommandException specialization: pull left conflicts in the working tree. Use Status() to see which paths are unmerged.
GitHostingExceptionBase type for every hosting-layer failure. Does not derive from GitException — it carries HTTP concepts, not process ones. Carries ProviderName, StatusCode, ResponseBody.
GitHostingAuthenticationExceptionA GitHostingException specialization: the host rejected the request as unauthenticated, or the credential no longer grants access.
GitHostingNotFoundExceptionA GitHostingException specialization: the requested resource does not exist, or is not visible to the caller's credentials.
GitHostingRateLimitExceptionA GitHostingException specialization: the caller has exhausted its request quota. Carries ResetsAt, when the host reported one.
GitHostingRequestExceptionA GitHostingException specialization for any other non-success response — a malformed request, a validation failure, or a server-side error.

Result Models

TypeDescription
GitStatusBranch, Upstream, Ahead, Behind, IsDetached, Entries, IsClean.
GitStatusEntryIndexState, WorkTreeState, Path, OriginalPath for one changed path.
GitCommitSha, TreeSha, ParentShas, Author, Committer, Subject, Body.
GitSignatureName, Email, Timestamp recorded on a commit.
GitBranchName, Sha, Upstream, IsCurrent, IsRemote.
GitRemoteName, FetchUrl, PushUrl.
GitDiffEntryKind, Path, OriginalPath, SimilarityPercent.
GitVersionMajor, Minor, Patch, Raw, plus AtLeast(major, minor).
GitFileStateEnum: Unmodified, Modified, Added, Deleted, Renamed, Copied, Untracked, Ignored, Unmerged, TypeChanged.
GitChangeKindEnum: Added, Copied, Deleted, Modified, Renamed, TypeChanged, Unmerged, Unknown.
GitUntrackedFilesModeEnum: No, Normal, All.
GitCompletedThe result of a mutating verb whose only outcome is success — add, checkout, branch creation/deletion, remote commands, and pull. Carries Arguments.
GitInitResultRepository, AlreadyExisted — the outcome of IGitClient.Init.
GitFetchResultUpdates, DetailAvailable, IsUpToDate — the outcome of Fetch(). IsUpToDate is gated on DetailAvailable so an empty Updates from a pre-2.41 git is never mistaken for "nothing changed".
GitPushResultUpdates, HasRejections — the outcome of Push().
GitRefUpdateKind, Reference, Source, OldSha, NewSha, Summary, IsRejected — one reference changed by a fetch or a push.
GitRefUpdateKindEnum: FastForward, Forced, Removed, Created, Rejected, UpToDate, TagUpdate, Unknown.
GitPullRequestNumber, Title, Description, SourceBranch, TargetBranch, Author, State, IsDraft, WebURI, CreatedAt — one pull request, as reported by a hosting provider. A null optional field means the host did not report that value.
GitPullRequestStateEnum: Open, Merged, Closed.

IGitHostingProvider

The contract every hosting provider implements: repository enumeration, pull request listing, and pull request creation, over whichever transport and authentication scheme the host requires.

Properties

NameTypeDescription
NameGitProviderNameDisplay name of the provider.
OwnerGitProviderOwnerThe owner of the repositories in this provider.
PersonaGUIDPersonaGUIDThe persona GUID used for authentication with the provider (from ktsu.CredentialCache).
IsAuthenticatedboolWhether requests this provider issues carry a credential. A cache entry that resolves to "proceed unauthenticated", and one of a type the provider cannot apply, both report false.

Methods

NameReturn TypeDescription
GetRepositoriesAsync(CancellationToken)Task<IReadOnlyList<GitRepository>>Retrieves the repositories Owner has, from the host. Coverage differs by host — see GitHubProvider and AzureDevOpsProvider below.
GetPullRequestsAsync(GitRepositoryName, CancellationToken)Task<IReadOnlyList<GitPullRequest>>Retrieves a repository's open pull requests. The filter is requested explicitly of the host, not left to its default.
CreatePullRequest(GitRepositoryName)IGitPullRequestCreateBuilderStarts building a pull request for a repository.

GitProvider

The abstract base both hosting providers derive from. Implements IGitHostingProvider and resolves credentials via TryGetCredential/ResolveCredential; each concrete provider builds its own HttpClient-based transport around that credential — see GitHubProvider and AzureDevOpsProvider below for how the two differ. public bool TryGetCredential(out Credential?) is declared here, not on IGitHostingProvider, and reports only whether the credential cache holds an entry for this provider's PersonaGUID. IsAuthenticated is the narrower question of whether a request would actually carry one.

Each provider type keeps one shared SocketsHttpHandler for the life of the process and builds a short-lived client or adapter over it per call, so a caller looping over many repositories reuses one connection pool rather than building and tearing down one per request. Neither provider is IDisposable, and neither ever disposes a handler injected for testing.

GitHubProvider

GitProvider implementation backed by Octokit. GetRepositoriesAsync returns only Owner's public repositories — GitHub's GET /users/{login}/repos does not honour authentication to reveal private ones, and supplying a token does not widen this.

AzureDevOpsProvider

GitProvider implementation built on a raw HttpClient against Azure DevOps's REST API (api-version=7.1) — no Azure DevOps client library is referenced (see the Introduction). GetPullRequestsAsync pages with $top and $skip until a short page comes back, so a repository with more open pull requests than one page costs more than one request and is never truncated. GetRepositoriesAsync issues exactly one request, because that endpoint documents no pagination.

Additional Properties

NameTypeDescription
ProjectAzureDevOpsProjectName?Scopes repository enumeration to a project, or null to enumerate the whole organisation. Required for GetPullRequestsAsync and CreatePullRequest — Azure DevOps has no project-less pull request endpoint, and calling either without it throws InvalidOperationException.

GetRepositoriesAsync returns everything the resolved credential can see — unlike GitHubProvider, there is no public-only restriction.

IGitPullRequestCreateBuilder

Collects a pull request's details before submitting it. From, Into, and Titled are required; Describing and AsDraft are optional. A missing required value throws InvalidOperationException from ExecuteAsync, not from the setter that left it unset, since parts may be supplied in any order.

NameReturn TypeDescription
From(GitBranchName)IGitPullRequestCreateBuilderSets the source branch.
Into(GitBranchName)IGitPullRequestCreateBuilderSets the target branch.
Titled(GitPullRequestTitle)IGitPullRequestCreateBuilderSets the title.
Describing(string)IGitPullRequestCreateBuilderSets the description.
AsDraft()IGitPullRequestCreateBuilderMarks the pull request as a draft.
ExecuteAsync(CancellationToken)Task<GitPullRequest>Submits the pull request to the host.

ServiceCollectionExtensions

NameReturn TypeDescription
AddGitIntegration(IServiceCollection)IServiceCollectionRegisters git integration with default options, invoking the git found on PATH.
AddGitIntegration(IServiceCollection, Action<GitOptions>)IServiceCollectionRegisters git integration with configured options. Idempotent per service.

Registers only the local layer. Hosting providers are constructed directly — see Working with a Hosting Provider — since neither GitHubProvider nor AzureDevOpsProvider has a constructor dependency a container could supply.

Semantic Types

TypeWraps
GitAuthorEmailCommit author or committer email address
GitAuthorNameCommit author or committer name
GitBranchNameBranch name
GitCommitMessageCommit message
GitCommitShaCommit object id (abbreviated or full, including SHA-256 repositories)
GitProviderNameHosting provider display name
GitProviderOwnerAccount or organization owning a repository
GitRefNameA branch, tag, SHA, or revision expression
GitRemoteNameRemote name
GitRepositoryNameRepository name
GitRepositoryRemotePathClone path or URL
GitRepositoryWebURIRepository web address
AzureDevOpsProjectNameAzure DevOps project name — scopes AzureDevOpsProvider repository enumeration, and required for its pull request operations
GitPullRequestNumberPull request's host-assigned number
GitPullRequestTitlePull request title
GitPullRequestAuthorHost's identifier for the account that opened a pull request (a GitHub login, or an Azure DevOps unique name)
GitPullRequestWebURIPull request web address

Contributing

Contributions are welcome! Feel free to open issues or submit pull requests.

License

This project is licensed under the MIT License. See the LICENSE.md file for details.