Compare commits

...

3 Commits

Author SHA1 Message Date
Jiayuan
1ca55c3d80 chore(daemon): skip squash commits in Co-authored-by hook
Test commit to verify the prepare-commit-msg hook appends the
Co-authored-by trailer automatically.

Co-authored-by: multica-agent <github@multica.ai>
2026-04-29 22:59:23 +02:00
Jiayuan
528976e547 feat(settings): add Co-authored-by toggle in workspace Labs settings
Add a workspace-level toggle to enable/disable the Co-authored-by
trailer for agent commits. Default is enabled (on).

Backend:
- Include workspace settings in daemon register response
- Store settings in daemon workspaceState
- Thread CoAuthoredByEnabled through WorktreeParams to conditionally
  install the prepare-commit-msg hook
- Parse co_authored_by_enabled from workspace settings JSONB

Frontend:
- Replace empty Labs tab placeholder with a Git section containing
  a Switch toggle for the Co-authored-by trailer setting
- Optimistically update the workspace query cache on toggle
2026-04-29 22:54:33 +02:00
Jiayuan
1ddd210170 feat(daemon): add Co-authored-by trailer for Multica Agent to git commits
Install a prepare-commit-msg hook in worktree bare repos that appends
"Co-authored-by: multica-agent <github@multica.ai>" to every commit
made by agents. Uses git interpret-trailers for proper formatting and
skips duplicates.
2026-04-29 22:40:32 +02:00
8 changed files with 279 additions and 32 deletions

View File

@@ -1,26 +1,83 @@
"use client";
import { FlaskConical } from "lucide-react";
import { useState } from "react";
import { GitCommitHorizontal } from "lucide-react";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Switch } from "@multica/ui/components/ui/switch";
import { Label } from "@multica/ui/components/ui/label";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { useCurrentWorkspace } from "@multica/core/paths";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import type { Workspace } from "@multica/core/types";
export function LabsTab() {
const workspace = useCurrentWorkspace();
const qc = useQueryClient();
const [saving, setSaving] = useState(false);
const coAuthoredByEnabled =
(workspace?.settings as Record<string, unknown>)?.co_authored_by_enabled !== false;
const handleToggle = async (checked: boolean) => {
if (!workspace || saving) return;
setSaving(true);
try {
const updated = await api.updateWorkspace(workspace.id, {
settings: {
...((workspace.settings as Record<string, unknown>) ?? {}),
co_authored_by_enabled: checked,
},
});
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] | undefined) =>
old?.map((ws) => (ws.id === updated.id ? updated : ws)),
);
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Failed to update setting",
);
} finally {
setSaving(false);
}
};
if (!workspace) return null;
return (
<div className="space-y-4">
<section className="space-y-4">
<h2 className="text-sm font-semibold">Labs</h2>
<h2 className="text-sm font-semibold">Git</h2>
<Card>
<CardContent>
<div className="flex items-start gap-3">
<div className="rounded-md border bg-muted/50 p-2 text-muted-foreground">
<FlaskConical className="h-4 w-4" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">No experimental features yet</p>
<p className="text-sm text-muted-foreground">
Beta features that require manual opt-in will appear here.
</p>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="rounded-md border bg-muted/50 p-2 text-muted-foreground">
<GitCommitHorizontal className="h-4 w-4" />
</div>
<div className="space-y-1">
<Label
htmlFor="co-authored-by"
className="text-sm font-medium"
>
Co-authored-by trailer
</Label>
<p className="text-sm text-muted-foreground">
Automatically add{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
Co-authored-by: multica-agent &lt;github@multica.ai&gt;
</code>{" "}
to commits made by agents.
</p>
</div>
</div>
<Switch
id="co-authored-by"
checked={coAuthoredByEnabled}
onCheckedChange={handleToggle}
disabled={saving}
/>
</div>
</CardContent>
</Card>

View File

@@ -295,9 +295,10 @@ func (c *Client) Deregister(ctx context.Context, runtimeIDs []string) error {
// RegisterResponse holds the server's response to a daemon registration.
type RegisterResponse struct {
Runtimes []Runtime `json:"runtimes"`
Repos []RepoData `json:"repos"`
ReposVersion string `json:"repos_version"`
Runtimes []Runtime `json:"runtimes"`
Repos []RepoData `json:"repos"`
ReposVersion string `json:"repos_version"`
Settings json.RawMessage `json:"settings,omitempty"`
}
func (c *Client) Register(ctx context.Context, req map[string]any) (*RegisterResponse, error) {

View File

@@ -30,6 +30,7 @@ type workspaceState struct {
runtimeIDs []string
reposVersion string // stored for future use: skip refresh when version unchanged
allowedRepoURLs map[string]struct{}
settings json.RawMessage // workspace settings (JSONB)
lastRepoSyncErr string
repoRefreshMu sync.Mutex
}
@@ -321,12 +322,13 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s
return resp, nil
}
func newWorkspaceState(workspaceID string, runtimeIDs []string, reposVersion string, repos []RepoData) *workspaceState {
func newWorkspaceState(workspaceID string, runtimeIDs []string, reposVersion string, repos []RepoData, settings json.RawMessage) *workspaceState {
return &workspaceState{
workspaceID: workspaceID,
runtimeIDs: runtimeIDs,
reposVersion: reposVersion,
allowedRepoURLs: repoAllowlist(repos),
settings: settings,
}
}
@@ -370,6 +372,25 @@ func (d *Daemon) workspaceLastRepoSyncErr(workspaceID string) string {
return ws.lastRepoSyncErr
}
// workspaceCoAuthoredByEnabled returns whether the Co-authored-by hook should
// be installed for the given workspace. Defaults to true when the setting is
// absent (new workspaces, older servers that don't send settings).
func (d *Daemon) workspaceCoAuthoredByEnabled(workspaceID string) bool {
d.mu.Lock()
defer d.mu.Unlock()
ws, ok := d.workspaces[workspaceID]
if !ok || len(ws.settings) == 0 {
return true // default: enabled
}
var s struct {
CoAuthoredByEnabled *bool `json:"co_authored_by_enabled"`
}
if err := json.Unmarshal(ws.settings, &s); err != nil || s.CoAuthoredByEnabled == nil {
return true // default: enabled
}
return *s.CoAuthoredByEnabled
}
func (d *Daemon) syncWorkspaceRepos(workspaceID string, repos []RepoData) {
if d.repoCache == nil {
return
@@ -510,7 +531,7 @@ func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context) error {
d.logger.Info("registered runtime", "workspace_id", id, "runtime_id", rt.ID, "provider", rt.Provider)
}
d.mu.Lock()
d.workspaces[id] = newWorkspaceState(id, runtimeIDs, resp.ReposVersion, resp.Repos)
d.workspaces[id] = newWorkspaceState(id, runtimeIDs, resp.ReposVersion, resp.Repos, resp.Settings)
for _, rt := range resp.Runtimes {
d.runtimeIndex[rt.ID] = rt
}

View File

@@ -554,7 +554,7 @@ func TestEnsureRepoReadyFastPathDoesNotRefresh(t *testing.T) {
if err := d.repoCache.Sync("ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil {
t.Fatalf("seed repo cache: %v", err)
}
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}})
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}}, nil)
if err := d.ensureRepoReady(context.Background(), "ws-1", sourceRepo); err != nil {
t.Fatalf("ensureRepoReady: %v", err)
@@ -576,7 +576,7 @@ func TestEnsureRepoReadyTrimsURL(t *testing.T) {
if err := d.repoCache.Sync("ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil {
t.Fatalf("seed repo cache: %v", err)
}
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}})
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}}, nil)
// URL with trailing whitespace should still hit the fast path.
if err := d.ensureRepoReady(context.Background(), "ws-1", " "+sourceRepo+" "); err != nil {
@@ -604,7 +604,7 @@ func TestEnsureRepoReadyRefreshesOnMiss(t *testing.T) {
ReposVersion: "v2",
})
})
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
if err := d.ensureRepoReady(context.Background(), "ws-1", sourceRepo); err != nil {
t.Fatalf("ensureRepoReady: %v", err)
@@ -627,7 +627,7 @@ func TestEnsureRepoReadyReturnsNotConfigured(t *testing.T) {
ReposVersion: "v1",
})
})
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
err := d.ensureRepoReady(context.Background(), "ws-1", "git@example.com:team/api.git")
if !errors.Is(err, ErrRepoNotConfigured) {
@@ -646,7 +646,7 @@ func TestEnsureRepoReadyReportsSyncFailure(t *testing.T) {
ReposVersion: "v1",
})
})
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
err := d.ensureRepoReady(context.Background(), "ws-1", missingRepo)
if err == nil || !strings.Contains(err.Error(), "repo is configured but not synced:") {
@@ -674,7 +674,7 @@ func TestEnsureRepoReadyConcurrentMissRefreshesOnce(t *testing.T) {
ReposVersion: "v2",
})
})
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
const concurrency = 8
var wg sync.WaitGroup

View File

@@ -158,11 +158,12 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim
}
result, err := d.repoCache.CreateWorktree(repocache.WorktreeParams{
WorkspaceID: req.WorkspaceID,
RepoURL: req.URL,
WorkDir: req.WorkDir,
AgentName: req.AgentName,
TaskID: req.TaskID,
WorkspaceID: req.WorkspaceID,
RepoURL: req.URL,
WorkDir: req.WorkDir,
AgentName: req.AgentName,
TaskID: req.TaskID,
CoAuthoredByEnabled: d.workspaceCoAuthoredByEnabled(req.WorkspaceID),
})
if err != nil {
d.logger.Error("repo checkout failed", "url", req.URL, "error", err)

View File

@@ -327,11 +327,12 @@ func setFetchRefspec(barePath, refspec string) error {
// WorktreeParams holds inputs for creating a worktree from a cached bare clone.
type WorktreeParams struct {
WorkspaceID string // workspace that owns the repo
RepoURL string // remote URL to look up in the cache
WorkDir string // parent directory for the worktree (e.g. task workdir)
AgentName string // for branch naming
TaskID string // for branch naming uniqueness
WorkspaceID string // workspace that owns the repo
RepoURL string // remote URL to look up in the cache
WorkDir string // parent directory for the worktree (e.g. task workdir)
AgentName string // for branch naming
TaskID string // for branch naming uniqueness
CoAuthoredByEnabled bool // install prepare-commit-msg hook for Co-authored-by trailer
}
// WorktreeResult describes a successfully created worktree.
@@ -401,6 +402,13 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
_ = excludeFromGit(worktreePath, pattern)
}
// Install Co-authored-by hook for Multica Agent attribution (if enabled).
if params.CoAuthoredByEnabled {
if err := installCoAuthoredByHook(worktreePath); err != nil {
c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err)
}
}
c.logger.Info("repo checkout: existing worktree updated",
"url", params.RepoURL,
"path", worktreePath,
@@ -426,6 +434,13 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
_ = excludeFromGit(worktreePath, pattern)
}
// Install Co-authored-by hook for Multica Agent attribution (if enabled).
if params.CoAuthoredByEnabled {
if err := installCoAuthoredByHook(worktreePath); err != nil {
c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err)
}
}
c.logger.Info("repo checkout: worktree created",
"url", params.RepoURL,
"path", worktreePath,
@@ -650,6 +665,58 @@ func bareHeadBranch(barePath string) string {
return ref
}
// prepareCommitMsgHook is the prepare-commit-msg hook script that appends a
// Co-authored-by trailer for the Multica Agent to every commit message.
const prepareCommitMsgHook = `#!/bin/sh
# Multica: add Co-authored-by trailer for the Multica Agent.
# Installed by the Multica daemon. Do not edit — it will be overwritten.
COMMIT_MSG_FILE="$1"
COMMIT_SOURCE="$2"
# Skip merge and squash commits.
case "$COMMIT_SOURCE" in
merge|squash) exit 0 ;;
esac
TRAILER="Co-authored-by: multica-agent <github@multica.ai>"
# Don't add if already present.
if grep -qF "$TRAILER" "$COMMIT_MSG_FILE"; then
exit 0
fi
# Use git interpret-trailers for proper formatting.
git interpret-trailers --in-place --trailer "$TRAILER" "$COMMIT_MSG_FILE"
`
// installCoAuthoredByHook installs a prepare-commit-msg git hook that appends
// a Co-authored-by trailer for the Multica Agent. The hook is installed in the
// git common directory (the bare repo for worktrees) so it applies to all
// worktrees created from this cache.
func installCoAuthoredByHook(worktreePath string) error {
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-common-dir")
out, err := cmd.Output()
if err != nil {
return fmt.Errorf("resolve git common dir: %w", err)
}
commonDir := strings.TrimSpace(string(out))
if !filepath.IsAbs(commonDir) {
commonDir = filepath.Join(worktreePath, commonDir)
}
hooksDir := filepath.Join(commonDir, "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
return fmt.Errorf("create hooks dir: %w", err)
}
hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
if err := os.WriteFile(hookPath, []byte(prepareCommitMsgHook), 0o755); err != nil {
return fmt.Errorf("write prepare-commit-msg hook: %w", err)
}
return nil
}
// excludeFromGit adds a pattern to the worktree's .git/info/exclude file.
func excludeFromGit(worktreePath, pattern string) error {
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-dir")

View File

@@ -883,6 +883,97 @@ func TestGetRemoteDefaultBranchUsesBareHeadHintForCustomDefault(t *testing.T) {
}
}
// TestCreateWorktreeInstallsCoAuthoredByHook verifies that CreateWorktree
// installs a prepare-commit-msg hook that appends a Co-authored-by trailer
// for the Multica Agent to every commit made in the worktree.
func TestCreateWorktreeInstallsCoAuthoredByHook(t *testing.T) {
t.Parallel()
sourceRepo := createTestRepo(t)
cacheRoot := t.TempDir()
cache := New(cacheRoot, testLogger())
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
t.Fatalf("sync failed: %v", err)
}
workDir := t.TempDir()
result, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: workDir,
AgentName: "Test Agent",
TaskID: "a1b2c3d4-0000-0000-0000-000000000000",
CoAuthoredByEnabled: true,
})
if err != nil {
t.Fatalf("CreateWorktree failed: %v", err)
}
// Make a commit in the worktree and verify the hook appends the trailer.
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
t.Fatalf("write test file: %v", err)
}
runGitAuthored(t, result.Path, "add", ".")
runGitAuthored(t, result.Path, "commit", "-m", "test commit")
// Read the commit message.
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
if err != nil {
t.Fatalf("git log failed: %v", err)
}
commitMsg := string(out)
expectedTrailer := "Co-authored-by: multica-agent <github@multica.ai>"
if !strings.Contains(commitMsg, expectedTrailer) {
t.Errorf("commit message missing Co-authored-by trailer.\ngot:\n%s", commitMsg)
}
}
// TestCoAuthoredByHookIdempotent verifies that the hook does not add a
// duplicate Co-authored-by trailer if one is already present in the message.
func TestCoAuthoredByHookIdempotent(t *testing.T) {
t.Parallel()
sourceRepo := createTestRepo(t)
cacheRoot := t.TempDir()
cache := New(cacheRoot, testLogger())
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
t.Fatalf("sync failed: %v", err)
}
workDir := t.TempDir()
result, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: workDir,
AgentName: "Test Agent",
TaskID: "b2c3d4e5-0000-0000-0000-000000000000",
CoAuthoredByEnabled: true,
})
if err != nil {
t.Fatalf("CreateWorktree failed: %v", err)
}
// Commit with the trailer already in the message.
trailer := "Co-authored-by: multica-agent <github@multica.ai>"
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
t.Fatalf("write test file: %v", err)
}
runGitAuthored(t, result.Path, "add", ".")
runGitAuthored(t, result.Path, "commit", "-m", "test commit\n\n"+trailer)
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
if err != nil {
t.Fatalf("git log failed: %v", err)
}
commitMsg := string(out)
// Count occurrences — should appear exactly once.
count := strings.Count(commitMsg, trailer)
if count != 1 {
t.Errorf("expected exactly 1 Co-authored-by trailer, found %d.\ngot:\n%s", count, commitMsg)
}
}
// TestGetRemoteDefaultBranchAmbiguousOriginReturnsEmpty verifies step 4's
// safe-scan gating: when the cache has multiple refs/remotes/origin/*
// entries, none match the common defaults, and none match the bare HEAD

View File

@@ -342,10 +342,19 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
})
repoResp := workspaceReposResponse(req.WorkspaceID, ws.Repos)
// Include workspace settings so the daemon can honour feature toggles
// (e.g. co_authored_by_enabled for the prepare-commit-msg hook).
var settings json.RawMessage
if len(ws.Settings) > 0 {
settings = json.RawMessage(ws.Settings)
}
writeJSON(w, http.StatusOK, map[string]any{
"runtimes": resp,
"repos": repoResp.Repos,
"repos_version": repoResp.ReposVersion,
"settings": settings,
})
}