Compare commits

..

5 Commits

Author SHA1 Message Date
Jiayuan
8260e11c8a Revert "feat(quick-create): add preset issue fields (#2002)"
This reverts commit a039c4d803.
2026-05-03 19:45:43 +02:00
Bohan Jiang
3dc3e49a47 fix(daemon): remove Co-authored-by hook when workspace setting is off (#2035)
* fix(daemon): remove Co-authored-by hook when workspace setting is off

The prepare-commit-msg hook is installed in the bare repo's shared
hooks dir, so once installed it persists across worktrees. CreateWorktree
only installed the hook when the setting was enabled, but never removed
it — so disabling the workspace toggle had no effect on subsequent
commits.

Add removeCoAuthoredByHook and call it in both CreateWorktree branches
when the setting is disabled. Use a marker comment in the hook script so
removal only deletes hooks the daemon owns; user-installed hooks at the
same path are left alone.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): recognize legacy Multica prepare-commit-msg hook on removal

The first cut of removeCoAuthoredByHook only recognized hooks installed
by the new code (containing the multicaHookMarker sentinel). Bare clones
already on disk from previous daemon releases carry the older script
without that line, so toggling the workspace setting off would have
treated them as user hooks and left the trailer in place — exactly the
state reported in MUL-1704.

Match against a list of known daemon signatures (current marker + the
legacy "Installed by the Multica daemon." comment), and add a test that
seeds the verbatim legacy hook before CreateWorktree(... disabled) to
keep recognition aligned with what production hosts actually have on
disk.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 21:09:16 +08:00
Bohan Jiang
ae9098637d feat(analytics): suppress PostHog $pageview on desktop tab/workspace switches (#2033)
* feat(analytics): suppress PostHog $pageview on desktop tab/workspace switches

Desktop tab switches were emitting a $pageview every time the user clicked
between already-open tabs (or workspaces), since the tracker fired on any
change to the resolved active path. Real-data audit showed this was the
single largest source of PostHog quota burn — desktop accounted for 51% of
all $pageviews at ~34 pv/user/30d vs web's ~10 — and the re-emitted paths
add no signal because the original navigation already fired.

Detect "tab switch" as `(workspace, tabId)` identity changing while the
surface stays `tab`, and skip the capture in that case while still updating
the ref so the next in-tab navigation compares against the right baseline.
Login transitions, overlay open/close, and intra-tab navigation continue
to fire as before.

Co-authored-by: multica-agent <github@multica.ai>

* fix(analytics): only suppress $pageview for re-activations of known tabs

Prior commit suppressed every (workspace, tabId) change while the surface
stayed `tab`, which also swallowed the first $pageview for newly opened
tabs (`openInNewTab` / `addTab`) and for cross-workspace `switchWorkspace`
into a not-yet-seen tab.

Track an observed `(workspace, tabId) → path` map seeded from the
persisted tab store on mount. Suppress only when the active key is
already in the map AND its recorded path matches the current path —
i.e. genuine re-activation of an already-known tab. New tabs and
cross-workspace navigation to a fresh tab now correctly emit one
pageview.

Adds a vitest covering the three behaviors GPT-Boy flagged plus the
intra-tab navigation, overlay/login transitions, and persistence-restored
mount paths. Wires the `@/` alias into `vitest.config.ts` so component
tests can resolve renderer-relative imports.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(analytics): reuse tab-store helpers and inline observed-tabs seed

Replace the two ad-hoc tab selectors with the existing
`useActiveTabIdentity()` + `getActiveTab()` helpers from tab-store, which
already provide the (slug, tabId) primitive pair and the active tab
lookup with the same stability guarantees.

Move the observed-tabs Map seeding from a useEffect into a synchronous
first-render initializer. The seed runs once per mount before any
state-driven effect, so the previous useEffect-then-defensive-fallback
pattern in the second effect was unreachable.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 20:54:29 +08:00
Kagura
cc94fbd305 fix: handle square brackets in agent names for mention parsing (#1992)
* fix: handle square brackets in agent names for mention parsing (#1991)

The mention regex used [^\]]* to match labels, which broke when agent
names contained square brackets (e.g. David[TF]). The ] inside the name
caused the regex to stop matching prematurely, silently dropping the
mention.

Changes:
- Backend (mention.go): Switch to .+? (non-greedy) anchored on
  ](mention:// to correctly match labels with brackets
- Frontend (mention-extension.ts): Same regex fix in tokenizer, plus
  escape [ and ] in renderMarkdown to prevent creating ambiguous
  markdown syntax
- Add comprehensive tests for ParseMentions covering bracket names

Fixes #1991

* fix: add optional chaining for match group access

Fixes TS2532: Object is possibly 'undefined' on match[1] when calling
.replace() in the mention tokenizer.

* fix: tighten mention tokenizer to reject ordinary Markdown links

- Replace .+? with (?:\\.|[^\]])+  in start() and tokenize() regexes
  so the label cannot cross a ]( Markdown link boundary
- Escaped brackets (\[ \]) from renderMarkdown() are still accepted
- Add frontend tokenizer/serializer round-trip tests:
  - Plain mention
  - Escaped brackets (David[TF]) round-trip
  - Normal Markdown link + mention on same line (regression)
  - Multiple links before mention
  - Nested brackets (Bot[v2][beta])
  - Issue mentions without @ prefix

Addresses review feedback on #1992.

* fix: add type assertions for tiptap MarkdownTokenizer interface in tests

The tiptap MarkdownTokenizer type allows start to be string | function
and tokenize to accept 3 arguments. Our extension always provides
single-arg functions, so cast them for TypeScript satisfaction.

Fixes CI typecheck failure in @multica/views package.

* fix: cast renderMarkdown to single-arg shape and reset file modes to 0644
2026-05-03 19:39:26 +08:00
ayakabot
a039c4d803 feat(quick-create): add preset issue fields (#2002)
Fixed: #2001
2026-05-03 19:37:12 +08:00
9 changed files with 826 additions and 36 deletions

View File

@@ -0,0 +1,243 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
// vi.hoisted shared state — every store mock reads the same object so each
// test can mutate it then re-render to drive the tracker.
const state = vi.hoisted(() => ({
user: null as { id: string } | null,
overlay: null as { type: string; invitationId?: string } | null,
activeWorkspaceSlug: null as string | null,
byWorkspace: {} as Record<
string,
{ activeTabId: string; tabs: { id: string; path: string }[] }
>,
capturePageview: vi.fn<(path?: string) => void>(),
}));
vi.mock("@multica/core/analytics", () => ({
capturePageview: state.capturePageview,
}));
// Auth store — single selector pattern (`s => s.user`).
vi.mock("@multica/core/auth", () => {
const useAuthStore = (selector: (s: typeof state) => unknown) =>
selector(state);
return { useAuthStore };
});
// Window overlay store — same shape.
vi.mock("@/stores/window-overlay-store", () => {
const useWindowOverlayStore = (selector: (s: typeof state) => unknown) =>
selector(state);
return { useWindowOverlayStore };
});
// Tab store — selectors read activeWorkspaceSlug + byWorkspace. Also expose
// getState() for the seed pass and the helpers the tracker imports
// (useActiveTabIdentity, getActiveTab) so we don't have to re-import them
// from the real store inside a mocked module.
vi.mock("@/stores/tab-store", () => {
const useTabStore = Object.assign(
(selector: (s: typeof state) => unknown) => selector(state),
{ getState: () => state },
);
const getActiveTab = (s: typeof state) => {
const slug = s.activeWorkspaceSlug;
if (!slug) return null;
const group = s.byWorkspace[slug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
};
const useActiveTabIdentity = () => ({
slug: state.activeWorkspaceSlug,
tabId: state.activeWorkspaceSlug
? (state.byWorkspace[state.activeWorkspaceSlug]?.activeTabId ?? null)
: null,
});
return { useTabStore, getActiveTab, useActiveTabIdentity };
});
import { PageviewTracker } from "./pageview-tracker";
function reset() {
state.user = { id: "u1" };
state.overlay = null;
state.activeWorkspaceSlug = null;
state.byWorkspace = {};
state.capturePageview.mockClear();
}
beforeEach(() => {
reset();
});
describe("PageviewTracker", () => {
it("suppresses pageview when switching to a previously-visible tab on its existing path", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tB", path: "/acme/inbox" },
],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
// Initial mount on tA — seeded as observed, no pageview because both
// tabs were already in the persisted store before the tracker mounted.
expect(state.capturePageview).not.toHaveBeenCalled();
// Switch to tB (already-known tab on its already-known path).
state.byWorkspace = {
acme: {
activeTabId: "tB",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tB", path: "/acme/inbox" },
],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).not.toHaveBeenCalled();
// Switch back to tA — still no pageview.
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tB", path: "/acme/inbox" },
],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).not.toHaveBeenCalled();
});
it("fires pageview when a new tab is opened (openInNewTab / addTab)", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Simulate openInNewTab("/acme/agents") → new tab tC added and activated.
state.byWorkspace = {
acme: {
activeTabId: "tC",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tC", path: "/acme/agents" },
],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenCalledTimes(1);
expect(state.capturePageview).toHaveBeenCalledWith("/acme/agents");
});
it("fires pageview when switchWorkspace opens a new path in another workspace", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Cross-workspace navigation: switchWorkspace("butter", "/butter/inbox")
// creates a fresh tab in the destination workspace and makes it active.
state.byWorkspace = {
acme: { activeTabId: "tA", tabs: [{ id: "tA", path: "/acme/issues" }] },
butter: {
activeTabId: "tD",
tabs: [{ id: "tD", path: "/butter/inbox" }],
},
};
state.activeWorkspaceSlug = "butter";
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenCalledTimes(1);
expect(state.capturePageview).toHaveBeenCalledWith("/butter/inbox");
});
it("fires pageview on intra-tab navigation (path changes for the same tabId)", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues/123" }],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenCalledTimes(1);
expect(state.capturePageview).toHaveBeenCalledWith("/acme/issues/123");
});
it("fires overlay and login pageviews and suppresses re-entry into the same tab afterward", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Open onboarding overlay.
state.overlay = { type: "onboarding" };
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenLastCalledWith("/onboarding");
// Close overlay back to the tab — the tab is already observed on
// /acme/issues so this is a re-activation, no pageview.
state.capturePageview.mockClear();
state.overlay = null;
rerender(<PageviewTracker />);
expect(state.capturePageview).not.toHaveBeenCalled();
// Logout fires /login.
state.user = null;
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenLastCalledWith("/login");
});
it("suppresses on initial mount when the active tab was restored from persistence", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
render(<PageviewTracker />);
// Restored tab — seeded, treated as a re-activation.
expect(state.capturePageview).not.toHaveBeenCalled();
});
});

View File

@@ -1,11 +1,17 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { capturePageview } from "@multica/core/analytics";
import { useAuthStore } from "@multica/core/auth";
import { useTabStore } from "@/stores/tab-store";
import {
getActiveTab,
useActiveTabIdentity,
useTabStore,
} from "@/stores/tab-store";
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
/**
* Fires a PostHog $pageview whenever the user's visible surface changes.
* Fires a PostHog $pageview whenever the user's visible surface changes,
* EXCEPT for re-activations of an already-known tab on its already-known
* path.
*
* Desktop has three layers that can own the visible page:
*
@@ -17,10 +23,18 @@ import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overl
* 3. Otherwise → the active tab's path (workspace-scoped, e.g.
* `/acme/issues/123`). Kept in sync by `useTabRouterSync`.
*
* The overlay takes precedence over the tab path because it is visually in
* front of the tab system; the logged-out state shadows both because the
* shell doesn't render at all yet. This keeps the `$pageview` stream aligned
* with what the user actually sees.
* Tab-switch suppression: re-activating an already-open tab surfaces a
* previously-visited path under a `(workspace, tabId)` we have already
* seen — the pageview was emitted when the user originally navigated
* there, so re-emitting on every switch just inflates PostHog billing
* without adding signal (real-data audit: desktop tab switches were
* ~50% of all `$pageview` events).
*
* Newly opened tabs (`openInNewTab`, `addTab`) and cross-workspace
* `switchWorkspace(slug, path)` to a previously-unseen tab still fire,
* because their key is not in the observed map yet. The map is seeded
* from the persisted tab store on first render so tabs restored from a
* previous session don't all re-emit on first activation.
*
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
* `initAnalytics`) so this component owns the event shape, matching the web
@@ -29,34 +43,75 @@ import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overl
export function PageviewTracker() {
const user = useAuthStore((s) => s.user);
const overlay = useWindowOverlayStore((s) => s.overlay);
const activeTabPath = useTabStore((s) => {
const slug = s.activeWorkspaceSlug;
if (!slug) return null;
const group = s.byWorkspace[slug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId)?.path ?? null;
});
const { slug: activeWorkspaceSlug, tabId: activeTabId } = useActiveTabIdentity();
const activeTabPath = useTabStore((s) => getActiveTab(s)?.path ?? null);
const path = resolvePath(user, overlay, activeTabPath);
// (slug:tabId) → last path observed while that tab was visible. Lets us
// tell "re-activating a tab on a path we already saw" (suppress) apart
// from "newly opened tab" or "intra-tab navigation" (fire). Seeded
// synchronously on first render from the persisted tab store so
// session-restored tabs don't re-emit on first click.
const observedTabsRef = useRef<Map<string, string> | null>(null);
if (observedTabsRef.current === null) {
const seed = new Map<string, string>();
for (const [slug, group] of Object.entries(useTabStore.getState().byWorkspace)) {
for (const tab of group.tabs) {
seed.set(`${slug}:${tab.id}`, tab.path);
}
}
observedTabsRef.current = seed;
}
const lastSurfaceRef = useRef<{
kind: "login" | "overlay" | "tab" | null;
key: string | null;
path: string | null;
}>({ kind: null, key: null, path: null });
useEffect(() => {
if (!path) return;
let kind: "login" | "overlay" | "tab";
let path: string;
let key: string | null = null;
if (!user) {
kind = "login";
path = "/login";
} else if (overlay) {
kind = "overlay";
path = overlayPath(overlay);
} else if (activeTabPath && activeTabId && activeWorkspaceSlug) {
kind = "tab";
key = `${activeWorkspaceSlug}:${activeTabId}`;
path = activeTabPath;
} else {
return;
}
const observed = observedTabsRef.current!;
const last = lastSurfaceRef.current;
const next = { kind, key, path };
if (kind === "tab" && key !== null) {
const knownPath = observed.get(key);
const isReactivation =
last.key !== key && knownPath !== undefined && knownPath === path;
observed.set(key, path);
if (isReactivation) {
lastSurfaceRef.current = next;
return;
}
}
const unchanged =
last.kind === kind && last.key === key && last.path === path;
if (unchanged) return;
capturePageview(path);
}, [path]);
lastSurfaceRef.current = next;
}, [user, overlay, activeWorkspaceSlug, activeTabId, activeTabPath]);
return null;
}
function resolvePath(
user: unknown,
overlay: WindowOverlay | null,
activeTabPath: string | null,
): string | null {
if (!user) return "/login";
if (overlay) return overlayPath(overlay);
return activeTabPath;
}
function overlayPath(overlay: WindowOverlay): string {
switch (overlay.type) {
case "new-workspace":

View File

@@ -1,8 +1,14 @@
import { resolve } from "path";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": resolve(__dirname, "src/renderer/src"),
},
},
test: {
globals: true,
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"],

View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { BaseMentionExtension } from "./mention-extension";
const tokenizer = BaseMentionExtension.config.markdownTokenizer!;
// The tiptap MarkdownTokenizer/renderMarkdown types have broad signatures
// (multi-arg overloads). Our extension always provides single-argument
// implementations, so cast for test convenience.
const startFn = tokenizer.start as (src: string) => number;
const tokenizeFn = tokenizer.tokenize as (
src: string,
) => { type: string; raw: string; attributes: Record<string, string> } | undefined;
const renderMarkdown = BaseMentionExtension.config.renderMarkdown as (
node: { attrs: Record<string, string> },
) => string;
function tokenize(src: string) {
const start = startFn(src);
if (start === -1) return undefined;
return tokenizeFn(src.slice(start));
}
describe("mention tokenizer", () => {
it("parses a plain mention", () => {
const token = tokenize("[@Alice](mention://member/aaa-bbb)");
expect(token).toBeDefined();
expect(token!.attributes.label).toBe("Alice");
expect(token!.attributes.type).toBe("member");
expect(token!.attributes.id).toBe("aaa-bbb");
});
it("parses a mention with escaped brackets (round-trip from renderMarkdown)", () => {
// renderMarkdown escapes brackets: David[TF] → David\[TF\]
const md = renderMarkdown({
attrs: { id: "aaa-bbb", label: "David[TF]", type: "agent" },
});
expect(md).toBe("[@David\\[TF\\]](mention://agent/aaa-bbb)");
const token = tokenize(md);
expect(token).toBeDefined();
expect(token!.attributes.label).toBe("David[TF]");
expect(token!.attributes.type).toBe("agent");
});
it("does not match an ordinary Markdown link before a mention", () => {
const src =
"Check [docs](https://example.com) - [@User](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)";
// start() must NOT land on the [docs] link at index 6
const start = startFn(src);
expect(start).toBeGreaterThan(6);
// tokenize from the correct start position
const token = tokenizeFn(src.slice(start));
expect(token).toBeDefined();
expect(token!.attributes.label).toBe("User");
expect(token!.attributes.id).toBe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
});
it("handles multiple ordinary links before a mention", () => {
const src =
"See [a](https://a.com) and [b](https://b.com) - [@Bot](mention://agent/abc-123)";
const start = startFn(src);
const token = tokenizeFn(src.slice(start));
expect(token).toBeDefined();
expect(token!.attributes.label).toBe("Bot");
});
it("round-trips an agent label with nested brackets", () => {
const md = renderMarkdown({
attrs: { id: "x-y-z", label: "Bot[v2][beta]", type: "agent" },
});
const token = tokenize(md);
expect(token).toBeDefined();
expect(token!.attributes.label).toBe("Bot[v2][beta]");
});
it("parses issue mentions without @ prefix", () => {
const token = tokenize("[MUL-123](mention://issue/aaa-bbb)");
expect(token).toBeDefined();
expect(token!.attributes.label).toBe("MUL-123");
expect(token!.attributes.type).toBe("issue");
});
});

View File

@@ -39,17 +39,25 @@ export const BaseMentionExtension = Mention.extend({
name: "mention",
level: "inline" as const,
start(src: string) {
return src.search(/\[@?[^\]]+\]\(mention:\/\//);
// Accept escaped brackets (\\[ \\]) and non-] chars in the label.
// This prevents matching ordinary Markdown links like [docs](url)
// that appear before a mention on the same line.
return src.search(/\[@?(?:\\.|[^\]])+\]\(mention:\/\//);
},
tokenize(src: string) {
// Label accepts escaped chars (\\[ \\]) or any non-] character.
// This prevents the label from crossing a ]( Markdown link boundary
// while still supporting bracket-containing names like "David\[TF\]".
const match = src.match(
/^\[@?([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
/^\[@?((?:\\.|[^\]])+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
);
if (!match) return undefined;
// Unescape backslash-escaped brackets that renderMarkdown may produce.
const rawLabel = match[1]?.replace(/\\\[/g, "[").replace(/\\\]/g, "]");
return {
type: "mention",
raw: match[0],
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
attributes: { label: rawLabel, type: match[2] ?? "member", id: match[3] },
};
},
},
@@ -59,6 +67,9 @@ export const BaseMentionExtension = Mention.extend({
renderMarkdown: (node: any) => {
const { id, label, type = "member" } = node.attrs || {};
const prefix = type === "issue" ? "" : "@";
return `[${prefix}${label ?? id}](mention://${type}/${id})`;
// Escape square brackets in the label so the markdown link syntax
// is not broken when the name contains [ or ] (e.g. "David[TF]").
const safeLabel = (label ?? id).replace(/\[/g, "\\[").replace(/\]/g, "\\]");
return `[${prefix}${safeLabel}](mention://${type}/${id})`;
},
});

View File

@@ -439,11 +439,19 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
_ = excludeFromGit(worktreePath, pattern)
}
// Install Co-authored-by hook for Multica Agent attribution (if enabled).
// Install or remove the Co-authored-by hook based on the workspace
// setting. The hook lives in the bare repo's shared hooks dir, so we
// must actively remove it when disabled — otherwise a previously
// installed hook keeps appending the trailer to every commit even
// after the user toggles the setting off.
if params.CoAuthoredByEnabled {
if err := installCoAuthoredByHook(worktreePath); err != nil {
c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err)
}
} else {
if err := removeCoAuthoredByHook(worktreePath); err != nil {
c.logger.Warn("repo checkout: remove co-authored-by hook failed (non-fatal)", "error", err)
}
}
c.logger.Info("repo checkout: existing worktree updated",
@@ -471,11 +479,17 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
_ = excludeFromGit(worktreePath, pattern)
}
// Install Co-authored-by hook for Multica Agent attribution (if enabled).
// Install or remove the Co-authored-by hook based on the workspace
// setting. See the existing-worktree branch above for why removal is
// required when the setting is disabled.
if params.CoAuthoredByEnabled {
if err := installCoAuthoredByHook(worktreePath); err != nil {
c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err)
}
} else {
if err := removeCoAuthoredByHook(worktreePath); err != nil {
c.logger.Warn("repo checkout: remove co-authored-by hook failed (non-fatal)", "error", err)
}
}
c.logger.Info("repo checkout: worktree created",
@@ -728,9 +742,29 @@ func bareHeadBranch(barePath string) string {
return ref
}
// multicaHookMarker is a sentinel comment embedded in every prepare-commit-msg
// hook installed by the daemon. removeCoAuthoredByHook uses it to recognize
// hooks it owns so it never deletes a hook installed by the user or another
// tool. Do not change without bumping the recognition logic.
const multicaHookMarker = "# multica:prepare-commit-msg:co-authored-by"
// daemonInstalledHookSignatures lists substrings that identify a
// prepare-commit-msg hook as one the daemon installed. removeCoAuthoredByHook
// treats a hook as Multica-owned if its content contains ANY of these
// substrings. The list deliberately includes the legacy comment that the
// daemon used before multicaHookMarker existed, so disabling the toggle on
// existing installations still cleans up old hooks seeded by previous daemon
// versions. Add to this list — never remove from it — so future tweaks to
// prepareCommitMsgHook keep recognizing every previously-shipped variant.
var daemonInstalledHookSignatures = []string{
multicaHookMarker,
"# Installed by the Multica daemon.",
}
// 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:prepare-commit-msg:co-authored-by
# Multica: add Co-authored-by trailer for the Multica Agent.
# Installed by the Multica daemon. Do not edit — it will be overwritten.
@@ -780,6 +814,55 @@ func installCoAuthoredByHook(worktreePath string) error {
return nil
}
// isDaemonInstalledHook reports whether a prepare-commit-msg hook on disk was
// installed by the Multica daemon (current or any previously released
// version). It returns false for hooks that don't carry any known daemon
// signature, so a user-installed hook at the same path is left alone.
func isDaemonInstalledHook(contents []byte) bool {
body := string(contents)
for _, sig := range daemonInstalledHookSignatures {
if strings.Contains(body, sig) {
return true
}
}
return false
}
// removeCoAuthoredByHook removes the prepare-commit-msg hook installed by
// installCoAuthoredByHook. It only deletes the file when the content matches
// a known daemon signature (current marker or any previously released hook
// content), so a user-installed prepare-commit-msg hook is never touched.
// Returns nil when no hook is present or when an unrelated hook occupies
// the path.
func removeCoAuthoredByHook(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)
}
hookPath := filepath.Join(commonDir, "hooks", "prepare-commit-msg")
contents, err := os.ReadFile(hookPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read prepare-commit-msg hook: %w", err)
}
if !isDaemonInstalledHook(contents) {
// Unrelated hook (user or third-party): leave it alone.
return nil
}
if err := os.Remove(hookPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove 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

@@ -1158,6 +1158,210 @@ func TestCoAuthoredByHookIdempotent(t *testing.T) {
}
}
// TestCreateWorktreeRemovesCoAuthoredByHookWhenDisabled verifies the toggle-off
// path: a bare cache that already carries the Multica prepare-commit-msg hook
// (e.g. from a prior worktree created with the setting on) must drop the hook
// when the next CreateWorktree call passes CoAuthoredByEnabled=false.
// Otherwise commits keep getting the trailer even after the user disables the
// workspace setting.
func TestCreateWorktreeRemovesCoAuthoredByHookWhenDisabled(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)
}
// First worktree: setting enabled → hook installed in the bare cache's
// shared hooks dir.
workDir1 := t.TempDir()
if _, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: workDir1,
AgentName: "Test Agent",
TaskID: "11111111-0000-0000-0000-000000000000",
CoAuthoredByEnabled: true,
}); err != nil {
t.Fatalf("CreateWorktree (enabled) failed: %v", err)
}
barePath := cache.Lookup("ws-1", sourceRepo)
hookPath := filepath.Join(barePath, "hooks", "prepare-commit-msg")
if _, err := os.Stat(hookPath); err != nil {
t.Fatalf("precondition: expected hook to be installed at %s: %v", hookPath, err)
}
// Second worktree on the same bare cache: setting disabled → hook must
// be removed and a commit in the new worktree must NOT carry the
// trailer.
workDir2 := t.TempDir()
result, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: workDir2,
AgentName: "Test Agent",
TaskID: "22222222-0000-0000-0000-000000000000",
CoAuthoredByEnabled: false,
})
if err != nil {
t.Fatalf("CreateWorktree (disabled) failed: %v", err)
}
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
t.Errorf("expected hook to be removed at %s, stat err=%v", hookPath, err)
}
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")
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)
if strings.Contains(commitMsg, "Co-authored-by: multica-agent") {
t.Errorf("commit unexpectedly carries the Co-authored-by trailer with setting disabled.\ngot:\n%s", commitMsg)
}
}
// TestCreateWorktreeRemovesLegacyCoAuthoredByHook verifies the migration
// path: bare clones already on disk from previous daemon versions carry a
// prepare-commit-msg hook that does NOT include the multicaHookMarker
// sentinel — only the older `# Installed by the Multica daemon.` comment.
// Toggling the workspace setting off must still remove those legacy hooks,
// otherwise users who flip the toggle in production keep seeing the trailer
// indefinitely (the exact bug reported in MUL-1704).
func TestCreateWorktreeRemovesLegacyCoAuthoredByHook(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)
}
// Seed the bare cache with the exact hook content shipped by the
// previous daemon release (no multicaHookMarker line). Keeping a
// verbatim copy here means the test fails if recognition logic ever
// drifts away from what production hosts actually have on disk.
const legacyHook = `#!/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"
`
barePath := cache.Lookup("ws-1", sourceRepo)
hooksDir := filepath.Join(barePath, "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatalf("create hooks dir: %v", err)
}
hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
if err := os.WriteFile(hookPath, []byte(legacyHook), 0o755); err != nil {
t.Fatalf("seed legacy hook: %v", err)
}
workDir := t.TempDir()
result, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: workDir,
AgentName: "Test Agent",
TaskID: "44444444-0000-0000-0000-000000000000",
CoAuthoredByEnabled: false,
})
if err != nil {
t.Fatalf("CreateWorktree (disabled) failed: %v", err)
}
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
t.Errorf("expected legacy hook to be removed at %s, stat err=%v", hookPath, err)
}
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")
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
if err != nil {
t.Fatalf("git log failed: %v", err)
}
if commitMsg := string(out); strings.Contains(commitMsg, "Co-authored-by: multica-agent") {
t.Errorf("commit unexpectedly carries the Co-authored-by trailer after legacy hook removal.\ngot:\n%s", commitMsg)
}
}
// TestRemoveCoAuthoredByHookPreservesUserHook verifies that the disable path
// only deletes hooks installed by the daemon. A prepare-commit-msg hook
// without the Multica marker (e.g. one a user added manually) must be left
// untouched even when CoAuthoredByEnabled=false.
func TestRemoveCoAuthoredByHookPreservesUserHook(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)
}
barePath := cache.Lookup("ws-1", sourceRepo)
hooksDir := filepath.Join(barePath, "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatalf("create hooks dir: %v", err)
}
hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
userHook := "#!/bin/sh\n# user hook, not Multica\nexit 0\n"
if err := os.WriteFile(hookPath, []byte(userHook), 0o755); err != nil {
t.Fatalf("seed user hook: %v", err)
}
workDir := t.TempDir()
if _, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: workDir,
AgentName: "Test Agent",
TaskID: "33333333-0000-0000-0000-000000000000",
CoAuthoredByEnabled: false,
}); err != nil {
t.Fatalf("CreateWorktree (disabled) failed: %v", err)
}
got, err := os.ReadFile(hookPath)
if err != nil {
t.Fatalf("user hook unexpectedly removed: %v", err)
}
if string(got) != userHook {
t.Errorf("user hook contents changed.\nwant:\n%s\ngot:\n%s", userHook, string(got))
}
}
// 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

@@ -10,7 +10,10 @@ type Mention struct {
// MentionRe matches [@Label](mention://type/id) or [Label](mention://issue/id) in markdown.
// The @ prefix is optional to support issue mentions which use [MUL-123](mention://issue/...).
var MentionRe = regexp.MustCompile(`\[@?[^\]]*\]\(mention://(member|agent|issue|all)/([0-9a-fA-F-]+|all)\)`)
// Uses .+? (non-greedy) instead of [^\]]* so labels containing square brackets
// (e.g. "David[TF]") are matched correctly — the ](mention:// anchor is specific
// enough to prevent over-matching.
var MentionRe = regexp.MustCompile(`\[@?(.+?)\]\(mention://(member|agent|issue|all)/([0-9a-fA-F-]+|all)\)`)
// IsMentionAll returns true if the mention is an @all mention.
func (m Mention) IsMentionAll() bool {
@@ -23,12 +26,12 @@ func ParseMentions(content string) []Mention {
seen := make(map[string]bool)
var result []Mention
for _, m := range matches {
key := m[1] + ":" + m[2]
key := m[2] + ":" + m[3]
if seen[key] {
continue
}
seen[key] = true
result = append(result, Mention{Type: m[1], ID: m[2]})
result = append(result, Mention{Type: m[2], ID: m[3]})
}
return result
}

View File

@@ -0,0 +1,101 @@
package util
import (
"testing"
)
func TestParseMentions(t *testing.T) {
tests := []struct {
name string
content string
want []Mention
}{
{
name: "simple agent mention",
content: "[@Agent](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) please fix",
want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
},
{
name: "agent name with square brackets",
content: "[@David[TF]](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) please fix",
want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
},
{
name: "agent name with nested brackets",
content: "[@Bot[v2][beta]](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) help",
want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
},
{
name: "multiple mentions with brackets",
content: "[@A[1]](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) and [@B[2]](mention://agent/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb)",
want: []Mention{
{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"},
{Type: "agent", ID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"},
},
},
{
name: "issue mention without @",
content: "[MUL-123](mention://issue/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) is related",
want: []Mention{{Type: "issue", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
},
{
name: "member mention",
content: "[@Bob](mention://member/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) look",
want: []Mention{{Type: "member", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
},
{
name: "all mention",
content: "[@All](mention://all/all) heads up",
want: []Mention{{Type: "all", ID: "all"}},
},
{
name: "deduplicate same mention",
content: "[@A](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) and again [@A](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)",
want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
},
{
name: "no mentions",
content: "just a plain comment",
want: nil,
},
{
name: "escaped brackets in label",
content: `[@David\[TF\]](mention://agent/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) hi`,
want: []Mention{{Type: "agent", ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseMentions(tt.content)
if len(got) != len(tt.want) {
t.Fatalf("ParseMentions() returned %d mentions, want %d\ngot: %+v\nwant: %+v", len(got), len(tt.want), got, tt.want)
}
for i := range got {
if got[i].Type != tt.want[i].Type || got[i].ID != tt.want[i].ID {
t.Errorf("mention[%d] = %+v, want %+v", i, got[i], tt.want[i])
}
}
})
}
}
func TestHasMentionAll(t *testing.T) {
tests := []struct {
name string
mentions []Mention
want bool
}{
{"empty", nil, false},
{"no all", []Mention{{Type: "agent", ID: "x"}}, false},
{"has all", []Mention{{Type: "all", ID: "all"}}, true},
{"mixed", []Mention{{Type: "agent", ID: "x"}, {Type: "all", ID: "all"}}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := HasMentionAll(tt.mentions); got != tt.want {
t.Errorf("HasMentionAll() = %v, want %v", got, tt.want)
}
})
}
}