diff --git a/README.md b/README.md index d471edbef..f96aa374f 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ Backlog.md merges the following layers (highest → lowest): Run `backlog config` with no arguments to launch the full interactive wizard. This is the same experience triggered from `backlog init` when you opt into advanced settings, and it walks through the complete configuration surface: - Cross-branch accuracy: `checkActiveBranches`, `remoteOperations`, and `activeBranchDays`. -- Git workflow: `autoCommit` and `bypassGitHooks`. +- Git workflow: `autoCommit`, `autoPull` (run `git pull --rebase` before each operation so reads/writes use the latest), `autoPush` (run `git push` after every commit so changes are shared immediately) — both work from the CLI, web UI and MCP — and `bypassGitHooks`. - ID formatting: enable or size `zeroPaddedIds`. - Editor integration: pick a `defaultEditor` with availability checks. - Definition of Done defaults: interactively add/remove/reorder/clear project-level `definition_of_done` checklist items. @@ -258,7 +258,7 @@ Run `backlog config` with no arguments to launch the full interactive wizard. Th Skipping the wizard (answering "No" during init) applies the safe defaults that ship with Backlog.md: - `checkActiveBranches=true`, `remoteOperations=true`, `activeBranchDays=30`. -- `autoCommit=false`, `bypassGitHooks=false`. +- `autoCommit=false`, `autoPull=false`, `autoPush=false`, `bypassGitHooks=false`. - `zeroPaddedIds` disabled. - `defaultEditor` unset (falls back to your environment). - `defaultPort=6420`, `autoOpenBrowser=true`. diff --git a/src/cli.ts b/src/cli.ts index bf66fa5d6..84821bd2a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -98,6 +98,8 @@ const CONFIG_GET_KEYS = [ "autoOpenBrowser", "remoteOperations", "autoCommit", + "autoPull", + "autoPush", "filesystemOnly", "bypassGitHooks", "zeroPaddedIds", @@ -115,6 +117,8 @@ const CONFIG_SET_KEYS = [ "defaultPort", "remoteOperations", "autoCommit", + "autoPull", + "autoPush", "filesystemOnly", "bypassGitHooks", "zeroPaddedIds", @@ -537,6 +541,21 @@ if (shouldRunMigration) { } const program = new Command(); + +// Auto-pull (rebase) from remote before every command when config.autoPull is enabled. +program.hook("preAction", async () => { + try { + const c = new Core(process.cwd()); + const cfg = await c.filesystem.loadConfig(); + if (cfg?.autoPull) { + c.gitOps.setConfig(cfg); + await c.gitOps.pull(); + } + } catch { + // auto-pull must never block a command + } +}); + program .name("backlog") .description("Backlog.md - Project management CLI") @@ -3993,6 +4012,12 @@ addHelpSchema(configCmd.command("get "), { case "autoCommit": console.log(config.autoCommit?.toString() || ""); break; + case "autoPull": + console.log(config.autoPull?.toString() || "false"); + break; + case "autoPush": + console.log(config.autoPush?.toString() || "false"); + break; case "filesystemOnly": console.log(config.filesystemOnly?.toString() || "false"); break; @@ -4011,7 +4036,7 @@ addHelpSchema(configCmd.command("get "), { default: console.error(`Unknown config key: ${key}`); console.error( - "Available keys: defaultEditor, projectName, defaultStatus, statuses, labels, milestones, definitionOfDone, dateFormat, maxColumnWidth, defaultPort, autoOpenBrowser, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays", + "Available keys: defaultEditor, projectName, defaultStatus, statuses, labels, milestones, definitionOfDone, dateFormat, maxColumnWidth, defaultPort, autoOpenBrowser, remoteOperations, autoCommit, autoPull, autoPush, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays", ); process.exit(1); } @@ -4120,6 +4145,30 @@ addHelpSchema(configCmd.command("set "), { } break; } + case "autoPull": { + const boolValue = value.toLowerCase(); + if (boolValue === "true" || boolValue === "1" || boolValue === "yes") { + config.autoPull = true; + } else if (boolValue === "false" || boolValue === "0" || boolValue === "no") { + config.autoPull = false; + } else { + console.error("autoPull must be true or false"); + process.exit(1); + } + break; + } + case "autoPush": { + const boolValue = value.toLowerCase(); + if (boolValue === "true" || boolValue === "1" || boolValue === "yes") { + config.autoPush = true; + } else if (boolValue === "false" || boolValue === "0" || boolValue === "no") { + config.autoPush = false; + } else { + console.error("autoPush must be true or false"); + process.exit(1); + } + break; + } case "filesystemOnly": { const boolValue = value.toLowerCase(); if (boolValue === "true" || boolValue === "1" || boolValue === "yes") { @@ -4210,7 +4259,7 @@ addHelpSchema(configCmd.command("set "), { default: console.error(`Unknown config key: ${key}`); console.error( - "Available keys: defaultEditor, projectName, defaultStatus, dateFormat, maxColumnWidth, autoOpenBrowser, defaultPort, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays", + "Available keys: defaultEditor, projectName, defaultStatus, dateFormat, maxColumnWidth, autoOpenBrowser, defaultPort, remoteOperations, autoCommit, autoPull, autoPush, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays", ); process.exit(1); } diff --git a/src/core/config-migration.ts b/src/core/config-migration.ts index be24950c2..237404255 100644 --- a/src/core/config-migration.ts +++ b/src/core/config-migration.ts @@ -16,6 +16,8 @@ export function migrateConfig(config: Partial): BacklogConfig { defaultPort: 6420, remoteOperations: true, autoCommit: false, + autoPull: false, + autoPush: false, bypassGitHooks: false, checkActiveBranches: true, activeBranchDays: 30, @@ -50,6 +52,8 @@ export function needsMigration(config: Partial): boolean { { field: "autoOpenBrowser", hasDefault: true }, { field: "remoteOperations", hasDefault: true }, { field: "autoCommit", hasDefault: true }, + // NB: autoPull/autoPush are intentionally omitted here (like bypassGitHooks/filesystemOnly): + // they must not force a config rewrite on existing projects. Absent => treated as false. ]; return expectedFieldsWithDefaults.some(({ field }) => { diff --git a/src/file-system/operations.ts b/src/file-system/operations.ts index c4e202849..7672d92e8 100644 --- a/src/file-system/operations.ts +++ b/src/file-system/operations.ts @@ -1398,6 +1398,12 @@ ${description || `Milestone: ${title}`}`, case "auto_commit": config.autoCommit = value.toLowerCase() === "true"; break; + case "auto_pull": + config.autoPull = value.toLowerCase() === "true"; + break; + case "auto_push": + config.autoPush = value.toLowerCase() === "true"; + break; case "filesystem_only": case "filesystemOnly": config.filesystemOnly = value.toLowerCase() === "true"; @@ -1444,6 +1450,8 @@ ${description || `Milestone: ${title}`}`, defaultPort: config.defaultPort, remoteOperations: config.remoteOperations, autoCommit: config.autoCommit, + autoPull: config.autoPull, + autoPush: config.autoPush, filesystemOnly: config.filesystemOnly, zeroPaddedIds: config.zeroPaddedIds, bypassGitHooks: config.bypassGitHooks, @@ -1474,6 +1482,8 @@ ${description || `Milestone: ${title}`}`, ...(config.defaultPort ? [`default_port: ${config.defaultPort}`] : []), ...(typeof config.remoteOperations === "boolean" ? [`remote_operations: ${config.remoteOperations}`] : []), ...(typeof config.autoCommit === "boolean" ? [`auto_commit: ${config.autoCommit}`] : []), + ...(typeof config.autoPull === "boolean" ? [`auto_pull: ${config.autoPull}`] : []), + ...(typeof config.autoPush === "boolean" ? [`auto_push: ${config.autoPush}`] : []), ...(typeof config.filesystemOnly === "boolean" ? [`filesystem_only: ${config.filesystemOnly}`] : []), ...(typeof config.zeroPaddedIds === "number" ? [`zero_padded_ids: ${config.zeroPaddedIds}`] : []), ...(typeof config.bypassGitHooks === "boolean" ? [`bypass_git_hooks: ${config.bypassGitHooks}`] : []), diff --git a/src/git/operations.ts b/src/git/operations.ts index becf0f543..30c672416 100644 --- a/src/git/operations.ts +++ b/src/git/operations.ts @@ -79,6 +79,7 @@ export class GitOperations { return; } await this.execGit(args, { cwd: repoRoot }); + await this.autoPushIfEnabled(repoRoot); } async commitChanges(message: string, repoRoot?: string | null): Promise { @@ -90,6 +91,7 @@ export class GitOperations { args.push("--no-verify"); } await this.execGit(args, { cwd: repoRoot ?? undefined }); + await this.autoPushIfEnabled(repoRoot); } async commitFiles(message: string, filePaths: string[], repoRoot?: string | null): Promise { @@ -130,6 +132,7 @@ export class GitOperations { } args.push("--", ...uniqueRelativePaths); await this.execGit(args, { cwd: resolvedRepoRoot }); + await this.autoPushIfEnabled(resolvedRepoRoot); } async resetIndex(repoRoot?: string | null): Promise { @@ -181,6 +184,31 @@ export class GitOperations { args.push("--no-verify"); } await this.execGit(args, { cwd: repoRoot ?? undefined }); + await this.autoPushIfEnabled(repoRoot); + } + + /** + * Push the current branch HEAD to the remote. Gated by `remoteOperations` and + * the presence of a remote; never throws, so it cannot block the calling + * operation (network down, non-fast-forward, no upstream, …). + */ + async push(remote = "origin", repoRoot?: string | null): Promise { + await this.loadConfigIfNeeded(); + if (this.config?.remoteOperations === false) return; + if (!(await this.hasAnyRemote())) return; + try { + await this.execGit(["push", "--quiet", remote, "HEAD"], { cwd: repoRoot ?? undefined }); + } catch (error) { + // Auto-push must never block a commit; swallow expected/transient failures. + if (process.env.DEBUG) console.warn(`Push skipped: ${error}`); + } + } + + /** After a successful commit, push to the remote when `config.autoPush` is enabled. Never throws. */ + private async autoPushIfEnabled(repoRoot?: string | null): Promise { + await this.loadConfigIfNeeded(); + if (!this.config?.autoPush) return; + await this.push("origin", repoRoot); } async retryGitOperation(operation: () => Promise, operationName: string, maxRetries = 3): Promise { @@ -278,6 +306,20 @@ export class GitOperations { } } + /** Pull (rebase, autostash) from remote into the working tree. Gated by remoteOperations; never throws. */ + async pull(remote = "origin"): Promise { + await this.loadConfigIfNeeded(); + if (this.config?.remoteOperations === false) return; + const hasRemotes = await this.hasAnyRemote(); + if (!hasRemotes) return; + try { + await this.execGit(["pull", "--rebase", "--autostash", "--quiet", remote]); + } catch (error) { + // Auto-pull must never block a command; swallow (network / no upstream / conflicts). + if (process.env.DEBUG) console.warn(`Pull skipped: ${error}`); + } + } + private isNetworkError(error: unknown): boolean { if (typeof error === "string") { return this.containsNetworkErrorPattern(error); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a97665660..4f022e41d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -402,6 +402,9 @@ export class McpServer extends Core { extra?: ServerRequestExtra, ): Promise { await this.ensureRootsResolved(extra); + // Pull before serving the tool so the AI reads/writes against the latest + // remote state (opt-in via config.autoPull). Mirrors the CLI preAction hook. + await this.maybeAutoPull(); const { name, arguments: args = {} } = request.params; const tool = this.tools.get(name); @@ -412,6 +415,18 @@ export class McpServer extends Core { return await tool.handler(args); } + /** Pull (rebase) from the remote before a tool call when config.autoPull is enabled. Never throws. */ + private async maybeAutoPull(): Promise { + try { + const config = await this.filesystem.loadConfig(); + if (!config?.autoPull) return; + this.gitOps.setConfig(config); + await this.gitOps.pull(); + } catch { + // auto-pull must never block a tool call + } + } + protected async listResources(extra?: ServerRequestExtra): Promise { await this.ensureRootsResolved(extra); return { diff --git a/src/server/index.ts b/src/server/index.ts index f4f7bda1d..1720d0f4d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -197,11 +197,61 @@ export class BacklogServer { private unsubscribeContentStore?: () => void; private storeReadyBroadcasted = false; private configWatcher: { stop: () => void } | null = null; + /** Timestamp (ms) of the last auto-pull, used to throttle read-triggered pulls. */ + private lastAutoPullAt = 0; + /** Reads pull at most once per this window so UI polling can't hammer the remote. */ + private static readonly AUTO_PULL_READ_THROTTLE_MS = 2000; constructor(projectPath: string) { this.core = new Core(projectPath, { enableWatchers: true }); } + /** + * Pull (rebase) from the remote before serving a request when config.autoPull + * is enabled. Mutations always pull (to avoid committing/pushing on top of a + * stale base); reads are throttled to AUTO_PULL_READ_THROTTLE_MS so frequent + * UI polling does not flood the remote. Never throws. + */ + private async maybeAutoPull(force: boolean): Promise { + try { + const config = await this.core.filesystem.loadConfig(); + if (!config?.autoPull) return; + if (!force && Date.now() - this.lastAutoPullAt < BacklogServer.AUTO_PULL_READ_THROTTLE_MS) { + return; + } + this.lastAutoPullAt = Date.now(); + this.core.gitOps.setConfig(config); + await this.core.gitOps.pull(); + } catch { + // auto-pull must never block a request + } + } + + /** + * Wrap every API route method handler so it auto-pulls before running (opt-in + * via config.autoPull). Mutations (POST/PUT/DELETE/PATCH) force a pull; GET is + * throttled. Non-API routes (SPA HTML, etc.) are left untouched. + */ + private applyAutoPullToRoutes(routes: Record): void { + const MUTATING = new Set(["POST", "PUT", "DELETE", "PATCH"]); + const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"]; + for (const key of Object.keys(routes)) { + const handlers = routes[key]; + if (!handlers || typeof handlers !== "object") continue; + const methodMap = handlers as Record; + for (const method of METHODS) { + const original = methodMap[method]; + if (typeof original !== "function") continue; + const force = MUTATING.has(method); + const fn = original as (...callArgs: unknown[]) => unknown; + methodMap[method] = async (...callArgs: unknown[]) => { + await this.maybeAutoPull(force); + return fn(...callArgs); + }; + } + } + } + private async resolveMilestoneInput(milestone: string): Promise { const [activeMilestones, archivedMilestones] = await Promise.all([ this.core.filesystem.listMilestones(), @@ -446,6 +496,7 @@ export class BacklogServer { }, /* biome-ignore format: keep cast on single line below for type narrowing */ }; + this.applyAutoPullToRoutes(serveOptions.routes as unknown as Record); this.server = Bun.serve(serveOptions as unknown as Parameters[0]); const url = `http://localhost:${finalPort}`; diff --git a/src/test/auto-push.test.ts b/src/test/auto-push.test.ts new file mode 100644 index 000000000..77150de62 --- /dev/null +++ b/src/test/auto-push.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { createUniqueTestDir, initializeTestProject, safeCleanup } from "./test-utils.ts"; + +let WORK_DIR: string; +let BARE_DIR: string; + +async function revParse(dir: string, ref: string): Promise { + const { stdout } = await $`git rev-parse ${ref}`.cwd(dir).quiet(); + return stdout.toString().trim(); +} + +describe("Auto-push after commit", () => { + let core: Core; + + beforeEach(async () => { + BARE_DIR = `${createUniqueTestDir("auto-push-remote")}.git`; + await mkdir(BARE_DIR, { recursive: true }); + await $`git init --bare -b main`.cwd(BARE_DIR).quiet(); + + WORK_DIR = createUniqueTestDir("auto-push-work"); + await rm(WORK_DIR, { recursive: true, force: true }).catch(() => {}); + await mkdir(WORK_DIR, { recursive: true }); + await $`git init -b main`.cwd(WORK_DIR).quiet(); + await $`git config user.email robot@host`.cwd(WORK_DIR).quiet(); + await $`git config user.name "Backlog Robot"`.cwd(WORK_DIR).quiet(); + await $`git remote add origin ${BARE_DIR}`.cwd(WORK_DIR).quiet(); + + core = new Core(WORK_DIR); + // Third arg seeds an initial commit; push it so origin/main exists. + await initializeTestProject(core, "Auto Push Project", true); + await $`git push -u origin main`.cwd(WORK_DIR).quiet(); + }); + + afterEach(async () => { + await safeCleanup(WORK_DIR); + await safeCleanup(BARE_DIR); + }); + + it("pushes to the remote after a commit when autoPush is enabled", async () => { + const config = await core.filesystem.loadConfig(); + if (!config) throw new Error("no config"); + config.autoCommit = true; + config.autoPush = true; + config.remoteOperations = true; + await core.filesystem.saveConfig(config); + + await core.createTaskFromInput({ title: "Pushed automatically" }, true); + + const localHead = await revParse(WORK_DIR, "HEAD"); + const remoteHead = await revParse(BARE_DIR, "main"); + expect(remoteHead).toBe(localHead); + }); + + it("does not push when autoPush is disabled", async () => { + const config = await core.filesystem.loadConfig(); + if (!config) throw new Error("no config"); + config.autoCommit = true; + config.autoPush = false; + config.remoteOperations = true; + await core.filesystem.saveConfig(config); + + const remoteBefore = await revParse(BARE_DIR, "main"); + await core.createTaskFromInput({ title: "Local only" }, true); + + const localHead = await revParse(WORK_DIR, "HEAD"); + const remoteAfter = await revParse(BARE_DIR, "main"); + // the local repo advanced, the remote did not + expect(localHead).not.toBe(remoteBefore); + expect(remoteAfter).toBe(remoteBefore); + }); + + it("does not push when remoteOperations is disabled, even with autoPush", async () => { + const config = await core.filesystem.loadConfig(); + if (!config) throw new Error("no config"); + config.autoCommit = true; + config.autoPush = true; + config.remoteOperations = false; + await core.filesystem.saveConfig(config); + + const remoteBefore = await revParse(BARE_DIR, "main"); + await core.createTaskFromInput({ title: "No remote ops" }, true); + + const remoteAfter = await revParse(BARE_DIR, "main"); + expect(remoteAfter).toBe(remoteBefore); + }); +}); diff --git a/src/test/config-commands.test.ts b/src/test/config-commands.test.ts index 3c526e1f1..ba2ff68a0 100644 --- a/src/test/config-commands.test.ts +++ b/src/test/config-commands.test.ts @@ -118,6 +118,28 @@ describe("Config commands", () => { expect(reloadedConfig?.autoCommit).toBe(true); }); + it("persists autoPull through save/reload and defaults to false", async () => { + const config = await core.filesystem.loadConfig(); + expect(config).not.toBeNull(); + if (!config) return; + expect(config.autoPull ?? false).toBe(false); + config.autoPull = true; + await core.filesystem.saveConfig(config); + const reloaded = await new Core(TEST_DIR).filesystem.loadConfig(); + expect(reloaded?.autoPull).toBe(true); + }); + + it("persists autoPush through save/reload and defaults to false", async () => { + const config = await core.filesystem.loadConfig(); + expect(config).not.toBeNull(); + if (!config) return; + expect(config.autoPush ?? false).toBe(false); + config.autoPush = true; + await core.filesystem.saveConfig(config); + const reloaded = await new Core(TEST_DIR).filesystem.loadConfig(); + expect(reloaded?.autoPush).toBe(true); + }); + it("configureAdvancedSettings supports add/remove/reorder/clear actions for Definition of Done defaults", async () => { const promptStub = createPromptStub([ { installCompletions: false }, diff --git a/src/test/mcp-auto-pull.test.ts b/src/test/mcp-auto-pull.test.ts new file mode 100644 index 000000000..0df532e51 --- /dev/null +++ b/src/test/mcp-auto-pull.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir } from "node:fs/promises"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { McpServer } from "../mcp/server.ts"; +import { registerTaskTools } from "../mcp/tools/tasks/index.ts"; +import { createUniqueTestDir, initializeTestProject, safeCleanup } from "./test-utils.ts"; + +let BARE_DIR: string; +let SEED_DIR: string; +let OTHER_DIR: string; +let MCP_DIR: string; + +async function rev(dir: string, ref: string): Promise { + const { stdout } = await $`git rev-parse ${ref}`.cwd(dir).quiet(); + return stdout.toString().trim(); +} + +async function configGit(dir: string) { + await $`git config user.email robot@host`.cwd(dir).quiet(); + await $`git config user.name "Backlog Robot"`.cwd(dir).quiet(); +} + +describe("MCP auto-pull before tool calls", () => { + beforeEach(async () => { + BARE_DIR = `${createUniqueTestDir("mcp-pull-remote")}.git`; + await mkdir(BARE_DIR, { recursive: true }); + await $`git init --bare -b main`.cwd(BARE_DIR).quiet(); + + // Seed the project and publish it to the bare remote. + SEED_DIR = createUniqueTestDir("mcp-pull-seed"); + await mkdir(SEED_DIR, { recursive: true }); + await $`git init -b main`.cwd(SEED_DIR).quiet(); + await configGit(SEED_DIR); + await $`git remote add origin ${BARE_DIR}`.cwd(SEED_DIR).quiet(); + const seedCore = new Core(SEED_DIR); + await initializeTestProject(seedCore, "MCP Pull Project", true); + await $`git push -u origin main`.cwd(SEED_DIR).quiet(); + + // The MCP clone — initially at the seed state. + MCP_DIR = createUniqueTestDir("mcp-pull-mcp"); + await $`git clone ${BARE_DIR} ${MCP_DIR}`.quiet(); + await configGit(MCP_DIR); + + // Another collaborator pushes a new commit after the MCP clone was made. + OTHER_DIR = createUniqueTestDir("mcp-pull-other"); + await $`git clone ${BARE_DIR} ${OTHER_DIR}`.quiet(); + await configGit(OTHER_DIR); + await Bun.write(`${OTHER_DIR}/backlog/docs/remote-note.md`, "# pushed by someone else\n"); + await $`git add backlog/docs/remote-note.md`.cwd(OTHER_DIR).quiet(); + await $`git commit -m "docs: remote change"`.cwd(OTHER_DIR).quiet(); + await $`git push origin main`.cwd(OTHER_DIR).quiet(); + }); + + afterEach(async () => { + await safeCleanup(BARE_DIR); + await safeCleanup(SEED_DIR); + await safeCleanup(OTHER_DIR); + await safeCleanup(MCP_DIR); + }); + + async function startMcp(autoPull: boolean): Promise { + const server = new McpServer(MCP_DIR, "Test instructions"); + await server.filesystem.ensureBacklogStructure(); + const config = await server.filesystem.loadConfig(); + if (!config) throw new Error("no config"); + config.autoPull = autoPull; + config.remoteOperations = true; + await server.filesystem.saveConfig(config); + registerTaskTools(server, config); + return server; + } + + it("pulls remote commits before a tool call when autoPull is enabled", async () => { + const remoteHead = await rev(BARE_DIR, "main"); + expect(await rev(MCP_DIR, "HEAD")).not.toBe(remoteHead); // behind at first + + const server = await startMcp(true); + try { + await server.testInterface.callTool({ params: { name: "task_list", arguments: {} } }); + expect(await rev(MCP_DIR, "HEAD")).toBe(remoteHead); // caught up via auto-pull + } finally { + await server.stop(); + } + }); + + it("does not pull when autoPull is disabled", async () => { + const before = await rev(MCP_DIR, "HEAD"); + const remoteHead = await rev(BARE_DIR, "main"); + expect(before).not.toBe(remoteHead); + + const server = await startMcp(false); + try { + await server.testInterface.callTool({ params: { name: "task_list", arguments: {} } }); + expect(await rev(MCP_DIR, "HEAD")).toBe(before); // unchanged + } finally { + await server.stop(); + } + }); +}); diff --git a/src/test/server-auto-pull.test.ts b/src/test/server-auto-pull.test.ts new file mode 100644 index 000000000..284228749 --- /dev/null +++ b/src/test/server-auto-pull.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir } from "node:fs/promises"; +import { $ } from "bun"; +import { Core } from "../core/backlog.ts"; +import { BacklogServer } from "../server/index.ts"; +import { createUniqueTestDir, initializeTestProject, retry, safeCleanup } from "./test-utils.ts"; + +let BARE_DIR: string; +let SEED_DIR: string; +let OTHER_DIR: string; +let WEB_DIR: string; +let server: BacklogServer | null = null; + +async function rev(dir: string, ref: string): Promise { + const { stdout } = await $`git rev-parse ${ref}`.cwd(dir).quiet(); + return stdout.toString().trim(); +} + +async function configGit(dir: string) { + await $`git config user.email robot@host`.cwd(dir).quiet(); + await $`git config user.name "Backlog Robot"`.cwd(dir).quiet(); +} + +async function startServer(dir: string, autoPull: boolean): Promise { + const core = new Core(dir); + const config = await core.filesystem.loadConfig(); + if (!config) throw new Error("no config"); + config.autoPull = autoPull; + config.remoteOperations = true; + await core.filesystem.saveConfig(config); + + server = new BacklogServer(dir); + await server.start(0, false); + const port = server.getPort(); + expect(port).toBeGreaterThan(0); + return port ?? 0; +} + +describe("BacklogServer auto-pull", () => { + beforeEach(async () => { + BARE_DIR = `${createUniqueTestDir("web-pull-remote")}.git`; + await mkdir(BARE_DIR, { recursive: true }); + await $`git init --bare -b main`.cwd(BARE_DIR).quiet(); + + SEED_DIR = createUniqueTestDir("web-pull-seed"); + await mkdir(SEED_DIR, { recursive: true }); + await $`git init -b main`.cwd(SEED_DIR).quiet(); + await configGit(SEED_DIR); + await $`git remote add origin ${BARE_DIR}`.cwd(SEED_DIR).quiet(); + await initializeTestProject(new Core(SEED_DIR), "Web Pull Project", true); + await $`git push -u origin main`.cwd(SEED_DIR).quiet(); + + WEB_DIR = createUniqueTestDir("web-pull-web"); + await $`git clone ${BARE_DIR} ${WEB_DIR}`.quiet(); + await configGit(WEB_DIR); + + OTHER_DIR = createUniqueTestDir("web-pull-other"); + await $`git clone ${BARE_DIR} ${OTHER_DIR}`.quiet(); + await configGit(OTHER_DIR); + await Bun.write(`${OTHER_DIR}/backlog/docs/remote-note.md`, "# remote change\n"); + await $`git add backlog/docs/remote-note.md`.cwd(OTHER_DIR).quiet(); + await $`git commit -m "docs: remote change"`.cwd(OTHER_DIR).quiet(); + await $`git push origin main`.cwd(OTHER_DIR).quiet(); + }); + + afterEach(async () => { + if (server) { + await server.stop(); + server = null; + } + await safeCleanup(BARE_DIR); + await safeCleanup(SEED_DIR); + await safeCleanup(OTHER_DIR); + await safeCleanup(WEB_DIR); + }); + + it("pulls remote commits on an API request when autoPull is enabled", async () => { + const remoteHead = await rev(BARE_DIR, "main"); + expect(await rev(WEB_DIR, "HEAD")).not.toBe(remoteHead); + + const port = await startServer(WEB_DIR, true); + + await retry( + async () => { + await fetch(`http://localhost:${port}/api/tasks`); + const head = await rev(WEB_DIR, "HEAD"); + if (head !== remoteHead) throw new Error("not pulled yet"); + return head; + }, + 20, + 100, + ); + + expect(await rev(WEB_DIR, "HEAD")).toBe(remoteHead); + }); + + it("does not pull when autoPull is disabled", async () => { + const before = await rev(WEB_DIR, "HEAD"); + const remoteHead = await rev(BARE_DIR, "main"); + expect(before).not.toBe(remoteHead); + + const port = await startServer(WEB_DIR, false); + await fetch(`http://localhost:${port}/api/tasks`); + // give any (unexpected) async pull a chance, then assert HEAD is unchanged + await fetch(`http://localhost:${port}/api/tasks`); + + expect(await rev(WEB_DIR, "HEAD")).toBe(before); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 8d2c6fee5..fe2b27d50 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -311,6 +311,10 @@ export interface BacklogConfig { defaultPort?: number; remoteOperations?: boolean; autoCommit?: boolean; + /** Auto-pull (rebase, autostash) from remote before each operation (CLI, web UI and MCP). Opt-in. */ + autoPull?: boolean; + /** Push to the remote automatically after every commit (CLI, web UI and MCP). Opt-in. */ + autoPush?: boolean; /** Disable all Git integration for filesystem-only projects. */ filesystemOnly?: boolean; zeroPaddedIds?: number;