feat(git): fetch/push/ff primitives with auth env, no task trailer in company repos

This commit is contained in:
mika kuns
2026-10-01 11:51:28 +02:00
parent bc0aa28afb
commit 76f260a9a5
4 changed files with 63 additions and 6 deletions
+43 -1
View File
@@ -450,6 +450,45 @@ public sealed class GitService
return (exitCode, stderr);
}
/// <summary>
/// <c>git fetch origin</c> for one branch into <c>refs/remotes/origin/&lt;branch&gt;</c>. The explicit
/// refspec works without a configured fetch refspec. <paramref name="env"/> carries the auth
/// header (never the command line) and must disable the terminal prompt.
/// </summary>
public async Task<(int ExitCode, string Stderr)> FetchBranchAsync(
string repoDir, string branch, IReadOnlyDictionary<string, string>? env, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["fetch", "origin", $"+refs/heads/{branch}:refs/remotes/origin/{branch}"], ct, env: env);
return (exitCode, stderr);
}
/// <summary>Moves the local, not checked-out <paramref name="branch"/> to <c>origin/&lt;branch&gt;</c>; git itself refuses a non-fast-forward. No network.</summary>
public async Task<(int ExitCode, string Stderr)> FastForwardLocalBranchAsync(
string repoDir, string branch, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["fetch", ".", $"refs/remotes/origin/{branch}:refs/heads/{branch}"], ct);
return (exitCode, stderr);
}
/// <summary><c>git push -u origin &lt;branch&gt;</c> from <paramref name="worktreePath"/>; auth via <paramref name="env"/>.</summary>
public async Task<(int ExitCode, string Stderr)> PushBranchAsync(
string worktreePath, string branch, IReadOnlyDictionary<string, string>? env, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["push", "-u", "origin", branch], ct, env: env);
return (exitCode, stderr);
}
/// <summary>Branch that <c>refs/remotes/origin/HEAD</c> points to (without <c>origin/</c>), or null.</summary>
public async Task<string?> GetRemoteHeadBranchAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], ct);
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) return null;
var head = stdout.Trim();
return head.StartsWith("origin/", StringComparison.Ordinal) ? head["origin/".Length..] : head;
}
/// <summary><c>git merge --ff-only</c>: moves the checked-out branch to <paramref name="sourceRef"/> or fails without touching it.</summary>
public async Task<(int ExitCode, string Stderr)> MergeFastForwardOnlyAsync(
string worktreePath, string sourceRef, CancellationToken ct = default)
@@ -721,7 +760,8 @@ public sealed class GitService
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true,
IReadOnlyDictionary<string, string>? env = null)
{
var psi = new ProcessStartInfo
{
@@ -742,6 +782,8 @@ public sealed class GitService
// of the OS locale git for Windows picks up.
psi.Environment["LC_ALL"] = "C";
psi.Environment["LANGUAGE"] = "C";
if (env is not null)
foreach (var (key, value) in env) psi.Environment[key] = value;
psi.ArgumentList.Add("-C");
psi.ArgumentList.Add(workDir);
foreach (var a in args) psi.ArgumentList.Add(a);
@@ -6,7 +6,9 @@ namespace ClaudeDo.Worker.Runner;
public static class CommitMessageBuilder
{
public static string Build(string commitType, string listName, string taskTitle, string? taskDescription, string taskId)
/// <summary>Task commit message; <paramref name="includeTrailer"/> is false in company repos, whose convention forbids signature trailers.</summary>
public static string Build(string commitType, string listName, string taskTitle, string? taskDescription, string taskId,
bool includeTrailer = true)
{
var slug = ToSlug(listName);
var title = Truncate(taskTitle, 60);
@@ -22,9 +24,11 @@ public static class CommitMessageBuilder
sb.Append(Truncate(taskDescription!.Trim(), 400));
}
// Trailer is always included.
sb.Append("\n\n");
sb.Append($"ClaudeDo-Task: {taskId}");
if (includeTrailer)
{
sb.Append("\n\n");
sb.Append($"ClaudeDo-Task: {taskId}");
}
return sb.ToString();
}
@@ -199,8 +199,10 @@ public sealed class WorktreeManager
await _git.AddAllAsync(ctx.WorktreePath, ct);
var isCompany = list.WorkingDir is not null
&& await CompanyRepo.IsCompanyAsync(_git, list.WorkingDir, _cfg.CompanyRemotePattern, ct);
var message = CommitMessageBuilder.Build(
task.CommitType, list.Name, task.Title, task.Description, task.Id);
task.CommitType, list.Name, task.Title, task.Description, task.Id, includeTrailer: !isCompany);
await _git.CommitAsync(ctx.WorktreePath, message, ct);
@@ -71,6 +71,15 @@ public class CommitMessageBuilderTests
Assert.Equal("ClaudeDo-Task: id-456", lines[4]);
}
[Fact]
public void WithoutTrailer_CompanyRepoCommitHasNoClaudeDoTaskLine()
{
var msg = CommitMessageBuilder.Build("feat", "Lager App", "add scan", "Detailed description here", "id-456",
includeTrailer: false);
Assert.Equal("feat(lager-app): add scan\n\nDetailed description here", msg);
}
[Fact]
public void BuildMerge_ConventionalHeaderWithMergeVerbAndTrailer()
{