Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CreativeCoders.Git.Abstractions.GitCommands;

public class FetchTagsCommandOptions
{
public bool Prune { get; set; } = true;

public string RemoteName { get; set; } = "origin";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CreativeCoders.Git.Abstractions.GitCommands;

public interface IFetchTagsCommand
{
void Execute(FetchTagsCommandOptions commandOptions);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ public interface IGitCommands
IPullCommand CreatePullCommand();

IPushCommand CreatePushCommand();

IFetchTagsCommand CreateFetchTagsCommand();
}
4 changes: 3 additions & 1 deletion source/Git/CreativeCoders.Git.Abstractions/Tags/IGitTag.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ public interface IGitTag : IEquatable<IGitTag?>, IComparable<IGitTag>, INamedRef
string TargetSha { get; }

IGitCommit? PeeledTargetCommit();
}

IGitCommit? TargetCommit { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ namespace CreativeCoders.Git.Abstractions.Tags;

public interface IGitTagCollection : IEnumerable<IGitTag>
{
IGitTag CreateTag(string tagName);
IGitTag CreateTag(string tagName, string? objectish = null);

IGitTag CreateTag(string tagName, string objectish);
IGitTag CreateTagWithMessage(string tagName, string message, string? objectish = null);

IGitTag CreateTagWithMessage(string tagName, string message);
void DeleteTag(string tagName, bool deleteOnRemote = false);

IGitTag CreateTagWithMessage(string tagName, string objectish, string message);
void DeleteTag(IGitTag tag, bool deleteOnRemote = false);

void DeleteRemoteTag(string tagName);

void PushTag(string tagName);

void PushTag(IGitTag tag);

void PushAllTags();
}
}
21 changes: 21 additions & 0 deletions source/Git/CreativeCoders.Git/GitCommands/FetchTagsCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using CreativeCoders.Git.Abstractions.GitCommands;

namespace CreativeCoders.Git.GitCommands;

internal class FetchTagsCommand(RepositoryContext repositoryContext) : IFetchTagsCommand
{
private readonly RepositoryContext _repositoryContext = Ensure.NotNull(repositoryContext);

public void Execute(FetchTagsCommandOptions commandOptions)
{
var fetchOptions = new FetchOptions
{
Prune = commandOptions.Prune,
TagFetchMode = TagFetchMode.All
};

Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FetchOptions here don't set a CredentialsProvider. The repository’s existing fetch path uses gitFetchOptions.ToFetchOptions(GetCredentialsHandler()), so this new command may fail against authenticated remotes. Set fetchOptions.CredentialsProvider using the repository context’s credentials handler (and keep behavior consistent with other fetch operations).

Suggested change
fetchOptions.CredentialsProvider = _repositoryContext.GetCredentialsHandler();

Copilot uses AI. Check for mistakes.
Commands.Fetch(_repositoryContext.LibGitRepository, commandOptions.RemoteName, ["+refs/tags/*:refs/tags/*"],
fetchOptions,
"Fetch all tags");
}
}
5 changes: 5 additions & 0 deletions source/Git/CreativeCoders.Git/GitCommands/GitCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,9 @@ public IPushCommand CreatePushCommand()
{
return new PushCommand(_repositoryContext);
}

public IFetchTagsCommand CreateFetchTagsCommand()
{
return new FetchTagsCommand(_repositoryContext);
}
}
7 changes: 7 additions & 0 deletions source/Git/CreativeCoders.Git/Tags/GitTag.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ internal GitTag(Tag tag)
_tag = Ensure.NotNull(tag);

Name = new ReferenceName(_tag.CanonicalName);

if (_tag.Target is Commit commit)
{
TargetCommit = GitCommit.From(commit);
}
}
Comment on lines +18 to 22
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TargetCommit is only populated when the tag directly targets a Commit (lightweight tags). For annotated tags (TagAnnotation), TargetCommit stays null even though PeeledTargetCommit() can resolve the commit, which will break tag sorting / display in the new CLI commands. Consider setting TargetCommit based on the peeled target (or implement TargetCommit to return PeeledTargetCommit()), so both lightweight and annotated tags have a commit when available.

Copilot uses AI. Check for mistakes.

static GitTag() => InitComparableObject(x => x.Name.Canonical);
Expand All @@ -34,5 +39,7 @@ internal GitTag(Tag tag)
return GitCommit.From(target as Commit);
}

public IGitCommit? TargetCommit { get; }

public static implicit operator Tag(GitTag tag) => tag._tag;
}
49 changes: 37 additions & 12 deletions source/Git/CreativeCoders.Git/Tags/GitTagCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,37 +19,62 @@ internal GitTagCollection(RepositoryContext context)
_libGitCaller = _context.LibGitCaller;
}

public IGitTag CreateTag(string tagName)
public IGitTag CreateTag(string tagName, string? objectish = null)
{
return new GitTag(_libGitCaller.Invoke(() => _repository.ApplyTag(tagName)));
return string.IsNullOrWhiteSpace(objectish)
? new GitTag(_libGitCaller.Invoke(() => _repository.ApplyTag(tagName)))
: new GitTag(_libGitCaller.Invoke(() => _repository.ApplyTag(tagName, objectish)));
}

public IGitTag CreateTag(string tagName, string objectish)
public IGitTag CreateTagWithMessage(string tagName, string message, string? objectish = null)
{
return new GitTag(_libGitCaller.Invoke(() => _repository.ApplyTag(tagName, objectish)));
return string.IsNullOrWhiteSpace(objectish)
? new GitTag(_libGitCaller.Invoke(() => _repository.ApplyTag(tagName, _context.GetSignature(), message)))
: new GitTag(_libGitCaller
.Invoke(() => _repository.ApplyTag(tagName, objectish, _context.GetSignature(), message)));
}

public IGitTag CreateTagWithMessage(string tagName, string message)
public void DeleteTag(string tagName, bool deleteOnRemote = false)
{
return new GitTag(_libGitCaller.Invoke(() => _repository.ApplyTag(tagName, _context.GetSignature(), message)));
_libGitCaller.Invoke(() => _repository.Tags.Remove(tagName));

if (deleteOnRemote)
{
DeleteRemoteTag(tagName);
}
}

public IGitTag CreateTagWithMessage(string tagName, string objectish, string message)
public void DeleteTag(IGitTag tag, bool deleteOnRemote = false)
{
return new GitTag(
_libGitCaller
.Invoke(() => _repository.ApplyTag(tagName, objectish, _context.GetSignature(), message)));
DeleteTag(tag.Name.Canonical, deleteOnRemote);
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DeleteTag(IGitTag ...) passes tag.Name.Canonical into DeleteTag(string ...). Since canonical tag names include the refs/tags/ prefix, this can produce an invalid remote delete refspec (:refs/tags/refs/tags/<name>) when deleteOnRemote is true. Pass tag.Name.Friendly (or otherwise normalize to a plain tag name) to avoid duplicating the prefix.

Suggested change
DeleteTag(tag.Name.Canonical, deleteOnRemote);
DeleteTag(tag.Name.Friendly, deleteOnRemote);

Copilot uses AI. Check for mistakes.
}

public void PushTag(IGitTag tag)
public void DeleteRemoteTag(string tagName)
{
var pushOptions = new PushOptions
{
CredentialsProvider = _context.GetCredentialsHandler()
};

_libGitCaller.Invoke(() =>
_repository.Network.Push(_repository.Network.Remotes[GitRemotes.Origin], $":refs/tags/{tagName}",
pushOptions));
}

public void PushTag(string tagName)
{
var pushOptions = new PushOptions
{
CredentialsProvider = _context.GetCredentialsHandler()
};

_libGitCaller.Invoke(() =>
_repository.Network.Push(_repository.Network.Remotes[GitRemotes.Origin], tag.Name.Canonical, pushOptions));
_repository.Network.Push(_repository.Network.Remotes[GitRemotes.Origin], tagName, pushOptions));
}
Comment on lines +64 to +73
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PushTag(string tagName) forwards tagName directly to Network.Push(...) as the refspec. Given the method name/signature, callers are likely to pass a friendly tag name (e.g. v1.2.3), not a canonical refspec (refs/tags/v1.2.3). Either normalize the input to a proper tag refspec before pushing, or rename/ document the parameter as a refspec to avoid subtle push failures.

Copilot uses AI. Check for mistakes.

public void PushTag(IGitTag tag)
{
PushTag(tag.Name.Canonical);
}

public void PushAllTags()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ public Task<CommandResult> ExecuteAsync(InfoBranchesOptions options)
_ansiConsole.PrintCommitLog(commits);
}

_ansiConsole.WriteLine();

return Task.FromResult(CommandResult.Success);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ private void PrintBranch(IGitBranch branch, int column0Width, int column1Width)
public Task<CommandResult> ExecuteAsync(ListBranchesOptions options)
{
_sysConsole
.WriteLine()
.WriteLine("List all branches:")
.WriteLine();

Expand All @@ -62,8 +61,6 @@ public Task<CommandResult> ExecuteAsync(ListBranchesOptions options)
// ReSharper disable once AccessToDisposedClosure
branches.ForEach(branch => PrintBranch(branch, column0Width, column1Width));

_sysConsole.WriteLine();

return Task.FromResult(CommandResult.Success);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ public async Task<CommandResult> ExecuteAsync(UpdateBranchesOptions options)
Ensure.NotNull(options);

_ansiConsole
.EmptyLine()
.WriteMarkupLine(_cml.Caption("Update permanent local branches"))
.EmptyLine();

Expand Down Expand Up @@ -73,8 +72,6 @@ public async Task<CommandResult> ExecuteAsync(UpdateBranchesOptions options)
_gitRepository.Branches.CheckOut(currentBranch.Name.Friendly);
}

_ansiConsole.EmptyLine();

return CommandResult.Success;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public async Task<CommandResult> ExecuteAsync(CreateReleaseOptions options)
_gitRepository.Pull();

var versionTag =
_gitRepository.Tags.CreateTagWithMessage(tagName, mainBranchName, $"Version {options.Version}");
_gitRepository.Tags.CreateTagWithMessage(tagName, $"Version {options.Version}", mainBranchName);

if (options.PushAllTags)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace CreativeCoders.GitTool.Cli.Commands.Shared;

public static class BoolExtensions
{
public static void IfElse(this bool condition, Action trueAction, Action falseAction)
{
if (condition)
{
trueAction();
}
else
{
falseAction();
}
}

public static T IfElse<T>(this bool condition, Func<T> trueFunc, Func<T> falseFunc)
{
return condition ? trueFunc() : falseFunc();
}

public static bool If(this bool condition, Action action)
{
if (!condition)
{
return false;
}

action();
return true;
}

public static void Else(this bool condition, Action action)
{
if (condition)
{
return;
}

action();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ public Task<int> ExecuteAsync(IGitRepository gitRepository, bool verbose)

PrintMergeResultStatus(mergeResult.MergeStatus);

_ansiConsole.WriteLine();

return Task.FromResult(0);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ public Task<int> ExecuteAsync(IGitRepository gitRepository, bool createRemoteIsN

pushCommand.Run();

_ansiConsole.WriteLine();

return Task.FromResult(0);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using CreativeCoders.Cli.Core;
using CreativeCoders.Core;
using CreativeCoders.Git.Abstractions;
using CreativeCoders.Git.Abstractions.Tags;
using CreativeCoders.SysConsole.Core;
using JetBrains.Annotations;
using Spectre.Console;

namespace CreativeCoders.GitTool.Cli.Commands.TagGroup.Create;

[UsedImplicitly]
[CliCommand([TagCommandGroup.Name, "create"], Description = "Creates a new tag")]
public class CreateTagCommand(IAnsiConsole ansiConsole, IGitRepository gitRepository) : ICliCommand<CreateTagOptions>
{
private readonly IAnsiConsole _ansiConsole = Ensure.NotNull(ansiConsole);

private readonly IGitRepository _gitRepository = Ensure.NotNull(gitRepository);

public Task<CommandResult> ExecuteAsync(CreateTagOptions options)
{
_ansiConsole.WriteLine($"Creating tag '{options.TagName}'...");

var tag = CreateTag(options);

_ansiConsole.MarkupLines(
$"Tag '{tag.Name.Friendly}' created.".ToSuccessMarkup(),
string.Empty,
$"Target commit: {tag.TargetCommit?.Id.Sha ?? "[none]"}".ToEscapedMarkup());

if (options.PushAfterCreate)
{
_ansiConsole.WriteLines(
string.Empty,
"Pushing tag after creation");

_gitRepository.Tags.PushTag(tag);

_ansiConsole.MarkupLine("Tag pushed.".ToSuccessMarkup());
}

return Task.FromResult(CommandResult.Success);
}

private IGitTag CreateTag(CreateTagOptions options)
{
return string.IsNullOrWhiteSpace(options.Message)
? _gitRepository.Tags.CreateTag(options.TagName, options.Objectish)
: _gitRepository.Tags.CreateTagWithMessage(options.TagName, options.Message, options.Objectish);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using CreativeCoders.SysConsole.Cli.Parsing;
using JetBrains.Annotations;

namespace CreativeCoders.GitTool.Cli.Commands.TagGroup.Create;

[PublicAPI]
public class CreateTagOptions
{
[OptionValue(0, IsRequired = true)] public string TagName { get; set; } = string.Empty;

[OptionParameter('p', "push", HelpText = "Push tag after creation")]
public bool PushAfterCreate { get; set; }

[OptionParameter('m', "message", HelpText = "Message for tag")]
public string? Message { get; set; }

[OptionParameter('o', "objectish", HelpText = "Object to tag")]
public string? Objectish { get; set; }
}
Loading