Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/docs/configure/learn.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ Delivery waits at most about 5 seconds for the learn lock; if another process st

In a project linked to an Altimate workspace (`altimate-code link`), what you teach the agent can become a **team rule**: a one-line instruction every teammate's sessions in the workspace's repositories follow once the workspace owner approves it.

**Rules need a git remote.** Team rules are kept per repository, keyed by its git remote. A project with no remote can be linked by its path, but rules stay off there: `learn status` and `learn sync` say so. Add a remote and run `altimate-code link` again, picking the same workspace: the link keeps its workspace and gains the remote, and rules sync from then on.

**When a rule is proposed.** A card appears above the input when:

- you correct the agent on the same topic in 2 or more sessions (also before the workspace has any rule);
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/altimate/rules/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export async function context(directory: string, learn: LearnConfig): Promise<Co
const outcome = await resolveBindingOutcome(directory).catch(() => ({ status: "unknown" as const }))
if (outcome.status === "unbound") return { status: "skipped", code: "not_linked", reason: "this project is not linked to a workspace", unlinked: true }
if (outcome.status !== "bound") return { status: "skipped", code: "link_unconfirmed", reason: "the workspace link could not be confirmed" }
if (!outcome.binding.repoRemote) return { status: "skipped", code: "no_remote", reason: "this project has no git remote" }
if (!outcome.binding.repoRemote) return { status: "skipped", code: "no_remote", reason: "this project is linked by its path, and workspace rules need a git remote on the link (add a remote if the project has none, then run `altimate-code link`)" }
const digest = credentialDigest(actAs.url, actAs.instance, actAs.apiKey)
// The binding was resolved with the ambient credential: it must still be the captured one.
if ((await accountDigest()) !== digest) return { status: "skipped", code: "account_changed", reason: "the Altimate account changed" }
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/src/altimate/workspace/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ export interface ProjectBindingLookup extends GetBindingResponse {
matchedBy: MatchedIdentifier
}

/**
* True when the link was found by path, has no remote, and the project has one now (a remote added after linking).
* Binding the same workspace again (`POST /bind`) stores the remote on the existing link; workspace rules are scoped
* by remote and stay off until it has one. A link with a different remote is not changed here: that is a re-link.
*/
export function addsRemoteToLink(identifier: ProjectIdentifier, existing: ProjectBindingLookup): boolean {
return existing.matchedBy === "path" && !!identifier.repoRemote && !existing.binding.repo_remote
}

/** What a link prints or shows when it adds the project's remote to a path link. */
export const ADDING_REMOTE_MESSAGE = "This project is linked by its path. Adding its git remote to the link, so workspace rules can sync."

export interface ConflictDetail {
message: string
existing_datamate_id?: number
Expand Down
17 changes: 15 additions & 2 deletions packages/opencode/src/cli/cmd/learn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { effectCmd, fail } from "../effect-cmd"
import { Flag } from "@opencode-ai/core/flag/flag"
import { RulesTelemetry } from "../../altimate/rules/telemetry"
import { printable } from "../../altimate/rules/printable"
import type { Context as RulesSyncContext } from "../../altimate/rules/sync"
import * as Playbook from "../../altimate/learn/playbook"
import * as Store from "../../altimate/learn/store"
import { MAX_TEXT, summarize, describeApplied, describeRejected } from "../../altimate/learn/curator"
Expand Down Expand Up @@ -426,8 +427,12 @@ const StatusCommand = effectCmd({
out(`Limits: ${Object.entries(status.limits).map(([key, value]) => `${key}=${value}`).join(", ")}`)
// altimate_change start — workspace rules
const rules = status.rules
if (!rules.linked) out("Workspace rules: not linked (run `altimate-code link`)")
else {
if (!rules.linked) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: learn status still decides whether to query the live rules gate from rules.linked, which is only the presence of previously pulled local workspace state. After a prior sync, removing the remote leaves that state populated while RulesSync.context() returns no_remote, so status shows stale active rules instead of explaining that rules are off. Resolve the current binding before choosing the linked-state output, and clear or suppress the stale mirror when the gate is no_remote.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/cli/cmd/learn.ts, line 430:

<comment>`learn status` still decides whether to query the live rules gate from `rules.linked`, which is only the presence of previously pulled local workspace state. After a prior sync, removing the remote leaves that state populated while `RulesSync.context()` returns `no_remote`, so status shows stale active rules instead of explaining that rules are off. Resolve the current binding before choosing the linked-state output, and clear or suppress the stale mirror when the gate is `no_remote`.</comment>

<file context>
@@ -426,8 +427,12 @@ const StatusCommand = effectCmd({
       const rules = status.rules
-      if (!rules.linked) out("Workspace rules: not linked (run `altimate-code link`)")
-      else {
+      if (!rules.linked) {
+        // Nothing synced yet: the rules gate says why (not linked, linked by path only, signed out, ...).
+        const [{ RulesSync }, { Instance }] = await Promise.all([import("../../altimate/rules/sync"), import("@/project/instance")])
</file context>

// Nothing synced yet: the rules gate says why (not linked, linked by path only, signed out, ...).
const [{ RulesSync }, { Instance }] = await Promise.all([import("../../altimate/rules/sync"), import("@/project/instance")])
const gate = await RulesSync.context(Instance.directory, learn).catch(() => undefined)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: the reason only reaches the human output. learn status --json returns before this gate runs, so a JSON consumer still sees only rules.linked: false and can't tell a path-only link from an unlinked project, which is the confusion the issue reports.

Suggestion: compute the gate before the args.json return and add e.g. rules.gate: { status, code, reason }. Separately, a nit: pass await RulesService.effectiveLearn(learn, root, Instance.directory) rather than the raw learn, as learn sync and line ~395 already do, so this line and the real sync gate use the same input.

Reviewed with independent Claude and Codex reviewers.

out(rulesNotSyncedLine(gate))
} else {
const { RulesSummary } = await import("../../altimate/rules/summary")
out(`Workspace rules: ${printable(rules.workspace?.name ?? "workspace")} — ${RulesSummary.sidebarLine(rules)}${rules.pulled_at ? `; last sync ${rules.pulled_at}` : ""}${rules.last_error ? `; last error: ${printable(rules.last_error)}` : ""}`)
if (rules.last_check) out(`Last proposal check: ${rules.last_check.at} — ${printable(rules.last_check.outcome)}`)
Expand All @@ -439,6 +444,14 @@ const StatusCommand = effectCmd({
})

// altimate_change start — workspace rules: send queued proposals, pull approved rules, report usage
/** The `learn status` rules line before any sync, from the rules gate. Undefined: the gate could not be read. */
export function rulesNotSyncedLine(gate: RulesSyncContext | undefined): string {
if (!gate) return "Workspace rules: not synced yet (run `altimate-code learn sync`)"
if (gate.status === "ready") return "Workspace rules: linked, not synced yet (run `altimate-code learn sync`)"
if (gate.status === "skipped" && gate.code === "not_linked") return "Workspace rules: not linked (run `altimate-code link`)"
return `Workspace rules: off — ${printable(gate.reason)}`
}

function noticeLine(notice: { status: string; text: string; reviewer?: string; reason?: string }) {
const status = notice.status === "active" ? "approved" : printable(notice.status)
return ` ${status}${notice.reviewer ? ` by ${printable(notice.reviewer)}` : ""}: "${printable(notice.text)}"${notice.reason ? ` — ${printable(notice.reason)}` : ""}`
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/src/cli/cmd/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
NotConfiguredError,
NotFoundError,
PreconditionFailedError,
ADDING_REMOTE_MESSAGE,
addsRemoteToLink,
type ActAs,
type Binding,
type DatamateRef,
Expand Down Expand Up @@ -436,6 +438,11 @@ export const LinkCommand = cmd({
}

const targetId = Number(pick)
if (targetId === currentId && existing && addsRemoteToLink(identifier, existing)) {
prompts.log.info(ADDING_REMOTE_MESSAGE)
await bindOrRebind(identifier, targetId, null, true, args.directory, actAs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This new bind can act on a stale pre-check from a different account: existing is looked up before actAs is captured, while the mutation runs as actAs. Capture one credential before the pre-check and pass it to getBindingForProject so the lookup, list, and bind share the same account.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/cli/cmd/link.ts, line 443:

<comment>This new bind can act on a stale pre-check from a different account: `existing` is looked up before `actAs` is captured, while the mutation runs as `actAs`. Capture one credential before the pre-check and pass it to `getBindingForProject` so the lookup, list, and bind share the same account.</comment>

<file context>
@@ -436,6 +438,11 @@ export const LinkCommand = cmd({
     const targetId = Number(pick)
+    if (targetId === currentId && existing && addsRemoteToLink(identifier, existing)) {
+      prompts.log.info(ADDING_REMOTE_MESSAGE)
+      await bindOrRebind(identifier, targetId, null, true, args.directory, actAs)
+      return
+    }
</file context>

return
}
if (targetId === currentId) {
prompts.outro(`Kept "${currentName}" — nothing changed.`)
return
Expand Down Expand Up @@ -1114,6 +1121,11 @@ export async function linkHeadless(
// An explicit `link` for the workspace the service already has: record the user's approval here (a fresh clone
// only knows the link as discovered, which `workspace sync` refuses), without a redundant server rebind.
const alreadyLinked = async (lookup: ProjectBindingLookup): Promise<void> => {
if (addsRemoteToLink(identifier, lookup)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: this branch no longer falls back to deps.approve, so a rejected bind now fails a link that used to succeed.

if (addsRemoteToLink(identifier, lookup)) {
  deps.print(ADDING_REMOTE_MESSAGE)
  await deps.bindOrRebind(identifier, lookup.datamate.id, null, actAs)
  return
}

Confirmed from the client code: if POST /bind returns 409, link --workspace <same> --yes (and the --create re-run at ~1157) exits 1 without recording approval. The 409 can come from the new remote already being bound elsewhere, for example to a teammate's private workspace that /by-remote hides. The error also says Already linked to "X". Re-run altimate-code link to switch., which is confusing when the user picked X. The same applies at link.ts:441 and in the TUI picker (workspace.tsx:1326). Not verified: whether a non-owner's bind can return 200 without storing the remote. If it can, the CLI says "Linked to …" while rules stay off.

Suggestion: if the add-remote bind fails, warn ("Could not add the git remote to the link (…); workspace rules stay off") and fall through to the existing approve path, so the command still exits 0. After a successful bind, warn when res.binding.repo_remote is still null.

Reviewed with independent Claude and Codex reviewers.

deps.print(ADDING_REMOTE_MESSAGE)
await deps.bindOrRebind(identifier, lookup.datamate.id, null, actAs)
return
}
let recorded: boolean
try {
recorded = await deps.approve(identifier, lookup, actAs)
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/plugin/tui/altimate/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ import {
NotFoundError,
PreconditionFailedError,
WorkspaceApi,
ADDING_REMOTE_MESSAGE,
addsRemoteToLink,
type Binding,
type ActAs,
type DatamateRef,
Expand Down Expand Up @@ -1218,6 +1220,8 @@ interface OnDemandPickerProps {
* correct rebind endpoint when the user swaps workspaces or picks Create
* on an already-linked project. (M3) */
matchedBy?: MatchedIdentifier
/** The link was found by path and the project has a remote it lacks: picking the linked workspace adds it. */
addsRemote?: boolean
defaultName: string
}

Expand Down Expand Up @@ -1319,6 +1323,12 @@ function OnDemandPickerDialog(props: OnDemandPickerProps) {
return
}
// Picked an existing workspace.
if (option.value === props.currentlyLinkedDatamateId && props.addsRemote) {
// A plain bind to the linked workspace: the service stores the remote on the existing link.
props.api.ui.toast({ variant: "info", message: ADDING_REMOTE_MESSAGE })
void bindOrRebindInline(props.api, props.identifier, option.value, undefined, listedAs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This new bind trusts a pre-check performed before the dialog captured listedAs, so an account switch can turn a stale path-link result into a bind for a colliding workspace ID. Capture credentials before the pre-check and use that same credential for the lookup and dialog bind.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/src/plugin/tui/altimate/workspace.tsx, line 1329:

<comment>This new bind trusts a pre-check performed before the dialog captured `listedAs`, so an account switch can turn a stale path-link result into a bind for a colliding workspace ID. Capture credentials before the pre-check and use that same credential for the lookup and dialog bind.</comment>

<file context>
@@ -1319,6 +1323,12 @@ function OnDemandPickerDialog(props: OnDemandPickerProps) {
+        if (option.value === props.currentlyLinkedDatamateId && props.addsRemote) {
+          // A plain bind to the linked workspace: the service stores the remote on the existing link.
+          props.api.ui.toast({ variant: "info", message: ADDING_REMOTE_MESSAGE })
+          void bindOrRebindInline(props.api, props.identifier, option.value, undefined, listedAs)
+          return
+        }
</file context>

return
}
if (option.value === props.currentlyLinkedDatamateId) {
// No-op — user picked the workspace this project is already linked to.
props.api.ui.toast({
Expand Down Expand Up @@ -1454,6 +1464,7 @@ async function runOnDemandPicker(api: TuiPluginApi, directory: string): Promise<
currentlyLinkedDatamateId={existing?.datamate.id}
currentlyLinkedDatamateName={existing?.datamate.name}
matchedBy={existing?.matchedBy}
addsRemote={existing ? addsRemoteToLink(identifier, existing) : false}
defaultName={defaultName}
/>
))
Expand Down
22 changes: 20 additions & 2 deletions packages/opencode/test/altimate/plugin/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -994,11 +994,11 @@ describe("link dialogs: a workspace that already has this project's name", () =>
expect(dialog.current).toBe(opensOn)
})

async function picker(list: { id: number; name: string; ownerId?: number }[]) {
async function picker(list: { id: number; name: string; ownerId?: number }[], linked: Record<string, unknown> = {}) {
stubWorkspaces(list)
stubCreds("acme", "https://api.acme.example.com")
const { tui, h } = harness()
render(() => linkDialogInternals.OnDemandPickerDialog({ api: tui, identifier, defaultName: "analytics" }))
render(() => linkDialogInternals.OnDemandPickerDialog({ api: tui, identifier, defaultName: "analytics", ...linked }))
await settle()
return { h, dialog: h.selects[0]! }
}
Expand Down Expand Up @@ -1054,6 +1054,24 @@ describe("link dialogs: a workspace that already has this project's name", () =>
expect(calls.bindAs).toEqual(["acme"])
})

test("picker: the linked workspace, linked by path only, is bound again so the link gains the remote", async () => {
const linked = { currentlyLinkedDatamateId: 2, currentlyLinkedDatamateName: "analytics", matchedBy: "path", addsRemote: true }
const { h, dialog } = await picker([{ id: 2, name: "analytics", ownerId: ME }], linked)
dialog.onSelect({ value: 2 })
await settle()
expect(calls.bind).toEqual([2])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This assertion does not verify the behavior named by the test: the bind stub discards the project identifier, so the test passes even if the rebind omits the Git remote. Record the bind arguments and assert that the path-only flow sends identifier.repoRemote (and the project path) to bindExisting.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/test/altimate/plugin/workspace.test.ts, line 1062:

<comment>This assertion does not verify the behavior named by the test: the bind stub discards the project identifier, so the test passes even if the rebind omits the Git remote. Record the bind arguments and assert that the path-only flow sends `identifier.repoRemote` (and the project path) to `bindExisting`.</comment>

<file context>
@@ -1054,6 +1054,24 @@ describe("link dialogs: a workspace that already has this project's name", () =>
+    const { h, dialog } = await picker([{ id: 2, name: "analytics", ownerId: ME }], linked)
+    dialog.onSelect({ value: 2 })
+    await settle()
+    expect(calls.bind).toEqual([2])
+    expect(h.toasts.some((m) => m.includes("Adding its git remote"))).toBe(true)
+  })
</file context>

expect(h.toasts.some((m) => m.includes("Adding its git remote"))).toBe(true)
})

test("picker: the linked workspace with nothing to add changes nothing", async () => {
const linked = { currentlyLinkedDatamateId: 2, currentlyLinkedDatamateName: "analytics", matchedBy: "remote" }
const { h, dialog } = await picker([{ id: 2, name: "analytics", ownerId: ME }], linked)
dialog.onSelect({ value: 2 })
await settle()
expect(calls.bind).toEqual([])
expect(h.toasts.some((m) => m.includes("nothing changed"))).toBe(true)
})

test("setup dialog: the namesake is bound as the credential it was listed under", async () => {
const { dialog } = await offer([{ id: 2, name: "analytics", ownerId: ME }])
dialog.onSelect({ value: "namesake" })
Expand Down
32 changes: 32 additions & 0 deletions packages/opencode/test/altimate/rules/rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { RuleProposal } from "../../../src/altimate/rules/proposal"
import { RulesStore } from "../../../src/altimate/rules/store"
import { RulesSummary } from "../../../src/altimate/rules/summary"
import { RulesSync } from "../../../src/altimate/rules/sync"
import { rulesNotSyncedLine } from "../../../src/cli/cmd/learn"
import { appliesTo, learningDecision, learningEnabled } from "../../../src/altimate/rules/trigger"
import { Config } from "../../../src/config/config"
import { WorkspaceApi } from "../../../src/altimate/workspace/api-client"
Expand Down Expand Up @@ -2336,3 +2337,34 @@ describe("cards across the feature's edges", () => {
expect(parse(await fs.readFile(file, "utf8"))).toEqual([])
})
})

describe("learn status before the first sync", () => {
test("a project linked by path says rules need a git remote, not 'not linked'", async () => {
const root = await tmp()
const env = process.env.ALTIMATE_DISABLE_WORKSPACE
delete process.env.ALTIMATE_DISABLE_WORKSPACE
const spies = [
spyOn(WorkspaceApi, "captureCredentials").mockResolvedValue({ url: "http://x/", instance: "t", apiKey: "k" }),
spyOn(WorkspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding: { datamateId: 1, repoRemote: null, projectPath: root } } as never),
]
try {
const gate = await RulesSync.context(root, { enabled: true })
expect(gate).toMatchObject({ status: "skipped", code: "no_remote" })
const line = rulesNotSyncedLine(gate)
expect(line).not.toContain("not linked")
expect(line).toContain("need a git remote")
expect(line).toContain("altimate-code link")
} finally {
for (const spy of spies) spy.mockRestore()
if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE
else process.env.ALTIMATE_DISABLE_WORKSPACE = env
}
})

test("the other gate outcomes", () => {
expect(rulesNotSyncedLine({ status: "skipped", code: "not_linked", reason: "x", unlinked: true })).toBe("Workspace rules: not linked (run `altimate-code link`)")
expect(rulesNotSyncedLine({ status: "ready" } as never)).toContain("not synced yet (run `altimate-code learn sync`)")
expect(rulesNotSyncedLine({ status: "skipped", code: "not_signed_in", reason: "not signed in to Altimate" })).toBe("Workspace rules: off — not signed in to Altimate")
expect(rulesNotSyncedLine(undefined)).toContain("not synced yet")
})
})
29 changes: 28 additions & 1 deletion packages/opencode/test/cli/workspace-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
import { SIGN_IN_COMMAND, linkHeadless, matchWorkspace, type LinkHeadlessDeps } from "../../src/cli/cmd/link"
import { AltimateAuthPlugin } from "../../src/altimate/plugin/altimate"
import { AltimateApi } from "../../src/altimate/api/client"
import { WorkspaceApi } from "../../src/altimate/workspace/api-client"
import { WorkspaceApi, addsRemoteToLink } from "../../src/altimate/workspace/api-client"
import yargs from "yargs"

const binding = {
Expand Down Expand Up @@ -380,6 +380,33 @@ describe("link --workspace / --create without prompting", () => {
expect(f.out.join("\n")).toContain("link confirmed on this machine")
})

test("linked by path, and the project has a remote now: binding the same workspace again adds the remote", async () => {
const pathOnly = { datamate: growth, binding: { repo_remote: null, project_path: dir }, matchedBy: "path" } as any
const seen: unknown[] = []
const f = linkDeps({
getBindingForProject: async () => pathOnly,
bindOrRebind: async (i, id, e) => void (seen.push(i.repoRemote, e), f.calls.push(`bind:${id}`)),
})
await linkHeadless({ directory: dir, workspace: "35", yes: false }, f.deps)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This test depends on the runner checkout having an origin remote, so it is non-hermetic and can fail in source distributions or CI checkout configurations without one. Create a temporary repository with a known remote (or inject the identifier) and assert the exact remote passed to bindOrRebind.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/opencode/test/cli/workspace-command.test.ts, line 390:

<comment>This test depends on the runner checkout having an `origin` remote, so it is non-hermetic and can fail in source distributions or CI checkout configurations without one. Create a temporary repository with a known remote (or inject the identifier) and assert the exact remote passed to `bindOrRebind`.</comment>

<file context>
@@ -380,6 +380,33 @@ describe("link --workspace / --create without prompting", () => {
+      getBindingForProject: async () => pathOnly,
+      bindOrRebind: async (i, id, e) => void (seen.push(i.repoRemote, e), f.calls.push(`bind:${id}`)),
+    })
+    await linkHeadless({ directory: dir, workspace: "35", yes: false }, f.deps)
+    expect(process.exitCode ?? 0).toBe(0)
+    // A plain bind (no "current" link) to the same workspace: the service stores the remote on the existing link.
</file context>

expect(process.exitCode ?? 0).toBe(0)
// A plain bind (no "current" link) to the same workspace: the service stores the remote on the existing link.
expect(f.calls).toEqual(["bind:35"])
expect(seen[0]).toBeTruthy()
expect(seen[1]).toBeNull()
expect(f.out.join("\n")).toContain("Adding its git remote")
})

test("the remote is added only to a path-matched link that has none, in a project that has one", () => {
const id = { repoRemote: "https://github.com/acme/demo", projectPath: "/p" }
const lookup = (matchedBy: "path" | "remote", repo_remote: string | null) =>
({ datamate: growth, binding: { repo_remote, project_path: "/p" }, matchedBy }) as any
expect(addsRemoteToLink(id, lookup("path", null))).toBe(true)
expect(addsRemoteToLink(id, lookup("remote", "https://github.com/acme/demo"))).toBe(false)
// The link already has a (different) remote: changing it is a re-link, not an addition.
expect(addsRemoteToLink(id, lookup("path", "https://github.com/acme/old"))).toBe(false)
expect(addsRemoteToLink({ projectPath: "/p" } as any, lookup("path", null))).toBe(false)
})

test("re-linking to a different workspace needs --yes", async () => {
const f = linkDeps({ getBindingForProject: async () => ({ datamate: growth, binding: {} }) as any })
await linkHeadless({ directory: dir, workspace: "9", yes: false }, f.deps)
Expand Down
Loading