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()
{