From 76f260a9a5c4e8cad1eac03c58af70d8dce4b90a Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 1 Oct 2026 11:51:28 +0200 Subject: [PATCH] feat(git): fetch/push/ff primitives with auth env, no task trailer in company repos --- src/ClaudeDo.Data/Git/GitService.cs | 44 ++++++++++++++++++- .../Runner/CommitMessageBuilder.cs | 12 +++-- src/ClaudeDo.Worker/Runner/WorktreeManager.cs | 4 +- .../Runner/CommitMessageBuilderTests.cs | 9 ++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/ClaudeDo.Data/Git/GitService.cs b/src/ClaudeDo.Data/Git/GitService.cs index 13cd34a4..8e950085 100644 --- a/src/ClaudeDo.Data/Git/GitService.cs +++ b/src/ClaudeDo.Data/Git/GitService.cs @@ -450,6 +450,45 @@ public sealed class GitService return (exitCode, stderr); } + /// + /// git fetch origin for one branch into refs/remotes/origin/<branch>. The explicit + /// refspec works without a configured fetch refspec. carries the auth + /// header (never the command line) and must disable the terminal prompt. + /// + public async Task<(int ExitCode, string Stderr)> FetchBranchAsync( + string repoDir, string branch, IReadOnlyDictionary? 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); + } + + /// Moves the local, not checked-out to origin/<branch>; git itself refuses a non-fast-forward. No network. + 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); + } + + /// git push -u origin <branch> from ; auth via . + public async Task<(int ExitCode, string Stderr)> PushBranchAsync( + string worktreePath, string branch, IReadOnlyDictionary? env, CancellationToken ct = default) + { + var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["push", "-u", "origin", branch], ct, env: env); + return (exitCode, stderr); + } + + /// Branch that refs/remotes/origin/HEAD points to (without origin/), or null. + public async Task 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; + } + /// git merge --ff-only: moves the checked-out branch to or fails without touching it. 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 args, CancellationToken ct, string? stdinData = null, bool trimOutput = true) + string workDir, IEnumerable args, CancellationToken ct, string? stdinData = null, bool trimOutput = true, + IReadOnlyDictionary? 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); diff --git a/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs b/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs index ab2b7dba..1365f940 100644 --- a/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs +++ b/src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs @@ -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) + /// Task commit message; is false in company repos, whose convention forbids signature trailers. + 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(); } diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index 420e1c25..6aa3fe61 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -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); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs index 5984c9b3..879566ef 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs @@ -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() {