From 5e9e52da0bd27ac53051b83487309fea1acd3523 Mon Sep 17 00:00:00 2001 From: tuxo83 Date: Sat, 27 Jun 2026 06:31:52 +0200 Subject: [PATCH 1/2] feat(config): add autoPull to sync from the remote before each operation (CLI, web and MCP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `auto_pull` is enabled, Backlog.md runs `git pull --rebase --autostash` before serving an operation, so reads and writes work against the latest remote state. Opt-in, off by default. Covers every entry point: - CLI: a commander preAction hook pulls before each command. - MCP: the server pulls before each tool call (so an AI reads/writes the latest). - Web: each API request pulls first — mutations always pull (so a commit/push is never built on a stale base); reads are throttled (AUTO_PULL_READ_THROTTLE_MS) so UI polling cannot flood the remote. New `pull()` git op: rebase+autostash, gated by `remote_operations` and the presence of a remote; never throws (network / no upstream / conflicts are swallowed) so it can't block a command, tool call or request. Config plumbing: type, migration default, load/save/serialize, CLI get/set, config-key lists and help text. Note: like `bypassGitHooks`/`filesystemOnly`, `autoPull` is intentionally NOT added to needsMigration — an absent value is treated as false and existing projects are not force-rewritten on load. Tests: CLI roundtrip of the config flag; MCP pulls a collaborator's pushed commit before a tool call (and does not when disabled); the web server pulls on an API request before serving it (and does not when disabled). --- README.md | 4 +- src/cli.ts | 30 ++++++++ src/core/config-migration.ts | 3 + src/file-system/operations.ts | 5 ++ src/git/operations.ts | 14 ++++ src/mcp/server.ts | 15 ++++ src/server/index.ts | 51 ++++++++++++++ src/test/config-commands.test.ts | 11 +++ src/test/mcp-auto-pull.test.ts | 100 +++++++++++++++++++++++++++ src/test/server-auto-pull.test.ts | 109 ++++++++++++++++++++++++++++++ src/types/index.ts | 2 + 11 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 src/test/mcp-auto-pull.test.ts create mode 100644 src/test/server-auto-pull.test.ts diff --git a/README.md b/README.md index d471edbef..dcdd807c5 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 — works 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`, `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..d9f38a02f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -537,6 +537,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 +4008,9 @@ addHelpSchema(configCmd.command("get "), { case "autoCommit": console.log(config.autoCommit?.toString() || ""); break; + case "autoPull": + console.log(config.autoPull?.toString() || "false"); + break; case "filesystemOnly": console.log(config.filesystemOnly?.toString() || "false"); break; @@ -4120,6 +4138,18 @@ 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 "filesystemOnly": { const boolValue = value.toLowerCase(); if (boolValue === "true" || boolValue === "1" || boolValue === "yes") { diff --git a/src/core/config-migration.ts b/src/core/config-migration.ts index be24950c2..9c4995b2f 100644 --- a/src/core/config-migration.ts +++ b/src/core/config-migration.ts @@ -16,6 +16,7 @@ export function migrateConfig(config: Partial): BacklogConfig { defaultPort: 6420, remoteOperations: true, autoCommit: false, + autoPull: false, bypassGitHooks: false, checkActiveBranches: true, activeBranchDays: 30, @@ -50,6 +51,8 @@ export function needsMigration(config: Partial): boolean { { field: "autoOpenBrowser", hasDefault: true }, { field: "remoteOperations", hasDefault: true }, { field: "autoCommit", hasDefault: true }, + // NB: autoPull is intentionally omitted here (like bypassGitHooks/filesystemOnly): + // it 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..8aee31676 100644 --- a/src/file-system/operations.ts +++ b/src/file-system/operations.ts @@ -1398,6 +1398,9 @@ ${description || `Milestone: ${title}`}`, case "auto_commit": config.autoCommit = value.toLowerCase() === "true"; break; + case "auto_pull": + config.autoPull = value.toLowerCase() === "true"; + break; case "filesystem_only": case "filesystemOnly": config.filesystemOnly = value.toLowerCase() === "true"; @@ -1444,6 +1447,7 @@ ${description || `Milestone: ${title}`}`, defaultPort: config.defaultPort, remoteOperations: config.remoteOperations, autoCommit: config.autoCommit, + autoPull: config.autoPull, filesystemOnly: config.filesystemOnly, zeroPaddedIds: config.zeroPaddedIds, bypassGitHooks: config.bypassGitHooks, @@ -1474,6 +1478,7 @@ ${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.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..6283f4b8e 100644 --- a/src/git/operations.ts +++ b/src/git/operations.ts @@ -278,6 +278,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/config-commands.test.ts b/src/test/config-commands.test.ts index 3c526e1f1..5438e9eb0 100644 --- a/src/test/config-commands.test.ts +++ b/src/test/config-commands.test.ts @@ -118,6 +118,17 @@ 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 core.filesystem.loadConfig(); + expect(reloaded?.autoPull).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..3397fcf9b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -311,6 +311,8 @@ export interface BacklogConfig { defaultPort?: number; remoteOperations?: boolean; autoCommit?: boolean; + /** Auto-pull (rebase, autostash) from remote before each command. */ + autoPull?: boolean; /** Disable all Git integration for filesystem-only projects. */ filesystemOnly?: boolean; zeroPaddedIds?: number; From 67036a526373eedf37fcd54af7ab103f0bfdcc53 Mon Sep 17 00:00:00 2001 From: tuxo83 Date: Sat, 27 Jun 2026 07:19:35 +0200 Subject: [PATCH 2/2] feat(config): add autoPush to push after every commit (CLI, web and MCP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symmetric counterpart of autoPull. When `auto_push` is enabled, Backlog.md runs `git push` right after each commit, so changes are shared immediately without a per-clone post-commit hook. Implemented at the commit layer (git/operations.ts), so it covers every entry point that writes through Core: the CLI, the web UI and the MCP server. This is the native replacement for a `.git/hooks/post-commit` "git push" hack. - Opt-in, off by default: `auto_push: false`. - New `push()` git op: pushes the current HEAD; gated by `remote_operations` and the presence of a remote; never throws (network / non-fast-forward / no upstream are swallowed) so it can't block a commit. - `autoPushIfEnabled()` is called after each of the 4 commit builders. - Config plumbing: type, migration default, load/save/serialize, CLI get/set, and the config-key lists + help text. Note: like `bypassGitHooks`/`filesystemOnly`, `autoPush` is intentionally NOT added to needsMigration — an absent value is treated as false and existing projects are not force-rewritten. Tests: real bare-remote push verified after a Core commit; no push when disabled; no push when remoteOperations is off; config save/reload roundtrip. --- README.md | 4 +- src/cli.ts | 23 ++++++++- src/core/config-migration.ts | 5 +- src/file-system/operations.ts | 5 ++ src/git/operations.ts | 28 ++++++++++ src/test/auto-push.test.ts | 89 ++++++++++++++++++++++++++++++++ src/test/config-commands.test.ts | 13 ++++- src/types/index.ts | 4 +- 8 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 src/test/auto-push.test.ts diff --git a/README.md b/README.md index dcdd807c5..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`, `autoPull` (run `git pull --rebase` before each operation so reads/writes use the latest — works from the CLI, web UI and MCP), 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`, `autoPull=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 d9f38a02f..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", @@ -4011,6 +4015,9 @@ addHelpSchema(configCmd.command("get "), { 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; @@ -4029,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); } @@ -4150,6 +4157,18 @@ addHelpSchema(configCmd.command("set "), { } 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") { @@ -4240,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 9c4995b2f..237404255 100644 --- a/src/core/config-migration.ts +++ b/src/core/config-migration.ts @@ -17,6 +17,7 @@ export function migrateConfig(config: Partial): BacklogConfig { remoteOperations: true, autoCommit: false, autoPull: false, + autoPush: false, bypassGitHooks: false, checkActiveBranches: true, activeBranchDays: 30, @@ -51,8 +52,8 @@ export function needsMigration(config: Partial): boolean { { field: "autoOpenBrowser", hasDefault: true }, { field: "remoteOperations", hasDefault: true }, { field: "autoCommit", hasDefault: true }, - // NB: autoPull is intentionally omitted here (like bypassGitHooks/filesystemOnly): - // it must not force a config rewrite on existing projects. Absent => treated as false. + // 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 8aee31676..7672d92e8 100644 --- a/src/file-system/operations.ts +++ b/src/file-system/operations.ts @@ -1401,6 +1401,9 @@ ${description || `Milestone: ${title}`}`, 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"; @@ -1448,6 +1451,7 @@ ${description || `Milestone: ${title}`}`, remoteOperations: config.remoteOperations, autoCommit: config.autoCommit, autoPull: config.autoPull, + autoPush: config.autoPush, filesystemOnly: config.filesystemOnly, zeroPaddedIds: config.zeroPaddedIds, bypassGitHooks: config.bypassGitHooks, @@ -1479,6 +1483,7 @@ ${description || `Milestone: ${title}`}`, ...(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 6283f4b8e..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 { 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 5438e9eb0..ba2ff68a0 100644 --- a/src/test/config-commands.test.ts +++ b/src/test/config-commands.test.ts @@ -125,10 +125,21 @@ describe("Config commands", () => { expect(config.autoPull ?? false).toBe(false); config.autoPull = true; await core.filesystem.saveConfig(config); - const reloaded = await core.filesystem.loadConfig(); + 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/types/index.ts b/src/types/index.ts index 3397fcf9b..fe2b27d50 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -311,8 +311,10 @@ export interface BacklogConfig { defaultPort?: number; remoteOperations?: boolean; autoCommit?: boolean; - /** Auto-pull (rebase, autostash) from remote before each command. */ + /** 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;