Skip to content
Closed
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,15 +250,15 @@ 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.
- Web UI defaults: choose `defaultPort` and whether `autoOpenBrowser` should run.

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`.
Expand Down
53 changes: 51 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ const CONFIG_GET_KEYS = [
"autoOpenBrowser",
"remoteOperations",
"autoCommit",
"autoPull",
"autoPush",
"filesystemOnly",
"bypassGitHooks",
"zeroPaddedIds",
Expand All @@ -115,6 +117,8 @@ const CONFIG_SET_KEYS = [
"defaultPort",
"remoteOperations",
"autoCommit",
"autoPull",
"autoPush",
"filesystemOnly",
"bypassGitHooks",
"zeroPaddedIds",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -3993,6 +4012,12 @@ addHelpSchema(configCmd.command("get <key>"), {
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;
Expand All @@ -4011,7 +4036,7 @@ addHelpSchema(configCmd.command("get <key>"), {
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);
}
Expand Down Expand Up @@ -4120,6 +4145,30 @@ addHelpSchema(configCmd.command("set <key> <value>"), {
}
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") {
Expand Down Expand Up @@ -4210,7 +4259,7 @@ addHelpSchema(configCmd.command("set <key> <value>"), {
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);
}
Expand Down
4 changes: 4 additions & 0 deletions src/core/config-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export function migrateConfig(config: Partial<BacklogConfig>): BacklogConfig {
defaultPort: 6420,
remoteOperations: true,
autoCommit: false,
autoPull: false,
autoPush: false,
bypassGitHooks: false,
checkActiveBranches: true,
activeBranchDays: 30,
Expand Down Expand Up @@ -50,6 +52,8 @@ export function needsMigration(config: Partial<BacklogConfig>): 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 }) => {
Expand Down
10 changes: 10 additions & 0 deletions src/file-system/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}`] : []),
Expand Down
42 changes: 42 additions & 0 deletions src/git/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand All @@ -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<void> {
Expand Down Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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<void> {
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<void> {
await this.loadConfigIfNeeded();
if (!this.config?.autoPush) return;
await this.push("origin", repoRoot);
}

async retryGitOperation<T>(operation: () => Promise<T>, operationName: string, maxRetries = 3): Promise<T> {
Expand Down Expand Up @@ -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<void> {
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);
Expand Down
15 changes: 15 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,9 @@ export class McpServer extends Core {
extra?: ServerRequestExtra,
): Promise<CallToolResult> {
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);

Expand All @@ -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<void> {
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<ListResourcesResult> {
await this.ensureRootsResolved(extra);
return {
Expand Down
51 changes: 51 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<string, unknown>): 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<string, unknown>;
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<string> {
const [activeMilestones, archivedMilestones] = await Promise.all([
this.core.filesystem.listMilestones(),
Expand Down Expand Up @@ -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<string, unknown>);
this.server = Bun.serve(serveOptions as unknown as Parameters<typeof Bun.serve>[0]);

const url = `http://localhost:${finalPort}`;
Expand Down
Loading