feat(FN-2622): merge fusion/fn-2622

This commit is contained in:
gsxdsm
2026-04-26 17:56:18 -07:00
parent b32f8ca275
commit f4d98ed717
4 changed files with 124 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add a `--git` flag to `fn init` to auto-initialize a git repository (including an initial commit) when the target directory is not already a git repo.

View File

@@ -220,7 +220,7 @@ fn — AI-orchestrated task board
Usage: Usage:
fn Launch the dashboard (same as fn dashboard) fn Launch the dashboard (same as fn dashboard)
fn init [opts] Initialize a new fn project in the current directory fn init [opts] Initialize a new fn project (--name, --path, --git)
fn dashboard Start the board web UI fn dashboard Start the board web UI
fn dashboard --paused Start with automation paused fn dashboard --paused Start with automation paused
fn dashboard --dev Start web UI only (no AI engine) fn dashboard --dev Start web UI only (no AI engine)
@@ -495,8 +495,9 @@ async function main() {
const name = nameIdx !== -1 && nameIdx + 1 < args.length ? args[nameIdx + 1] : undefined; const name = nameIdx !== -1 && nameIdx + 1 < args.length ? args[nameIdx + 1] : undefined;
const pathIdx = args.indexOf("--path"); const pathIdx = args.indexOf("--path");
const path = pathIdx !== -1 && pathIdx + 1 < args.length ? args[pathIdx + 1] : undefined; const path = pathIdx !== -1 && pathIdx + 1 < args.length ? args[pathIdx + 1] : undefined;
const git = args.includes("--git");
await runInit({ name, path }); await runInit({ name, path, git });
break; break;
} }

View File

@@ -7,6 +7,10 @@ import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { runInit } from "../init.js"; import { runInit } from "../init.js";
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const mockCentralInit = vi.fn(); const mockCentralInit = vi.fn();
const mockCentralClose = vi.fn(); const mockCentralClose = vi.fn();
@@ -31,6 +35,11 @@ function tempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix)); return mkdtempSync(join(tmpdir(), prefix));
} }
async function git(command: string, cwd: string): Promise<string> {
const { stdout } = await execAsync(command, { cwd, timeout: 10_000 });
return stdout.trim();
}
describe("init command", () => { describe("init command", () => {
let tempProjectDir: string; let tempProjectDir: string;
let tempHomeDir: string; let tempHomeDir: string;
@@ -247,4 +256,51 @@ describe("init command", () => {
expect(fusionMatches).toHaveLength(1); expect(fusionMatches).toHaveLength(1);
expect(piMatches).toHaveLength(1); expect(piMatches).toHaveLength(1);
}); });
it("initializes git when --git is enabled in a non-git directory", async () => {
expect(existsSync(join(tempProjectDir, ".git"))).toBe(false);
await runInit({ path: tempProjectDir, git: true });
expect(existsSync(join(tempProjectDir, ".git"))).toBe(true);
});
it("creates an initial commit when --git initializes a repository", async () => {
await runInit({ path: tempProjectDir, git: true });
const commitCount = await git("git rev-list --count HEAD", tempProjectDir);
expect(Number(commitCount)).toBeGreaterThanOrEqual(1);
});
it("does not reinitialize git when repository already exists", async () => {
await git("git init", tempProjectDir);
await git("git checkout -b main", tempProjectDir);
await git('git config user.name "Existing User"', tempProjectDir);
await git('git config user.email "existing@example.com"', tempProjectDir);
writeFileSync(join(tempProjectDir, "README.md"), "# Existing Repo\n");
await git("git add README.md", tempProjectDir);
await git('git commit -m "existing commit"', tempProjectDir);
await runInit({ path: tempProjectDir, git: true });
const commitCount = await git("git rev-list --count HEAD", tempProjectDir);
expect(Number(commitCount)).toBe(1);
});
it("does not create git repository without --git and logs a hint", async () => {
const originalLog = console.log;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
logs.push(args.join(" "));
};
try {
await runInit({ path: tempProjectDir });
} finally {
console.log = originalLog;
}
expect(existsSync(join(tempProjectDir, ".git"))).toBe(false);
expect(logs.join("\n")).toContain("Not a git repository. Run 'fn init --git' to auto-initialize one.");
});
}); });

View File

@@ -15,6 +15,7 @@ import { promisify } from "node:util";
const execAsync = promisify(exec); const execAsync = promisify(exec);
import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable } from "@fusion/core"; import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable } from "@fusion/core";
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js"; import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
import { isGitRepo } from "./git.js";
import { import {
installBundledFusionSkill, installBundledFusionSkill,
type SkillInstallResult, type SkillInstallResult,
@@ -26,6 +27,8 @@ export interface InitOptions {
name?: string; name?: string;
/** Path to initialize (defaults to cwd) */ /** Path to initialize (defaults to cwd) */
path?: string; path?: string;
/** Initialize a git repository if one does not exist */
git?: boolean;
} }
/** /**
@@ -78,6 +81,14 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
console.log(` ✓ Created .fusion/ directory`); console.log(` ✓ Created .fusion/ directory`);
} }
const hasGitRepo = await isGitRepo(cwd);
if (!hasGitRepo && options.git) {
await initializeGitRepo(cwd);
console.log(` ✓ Initialized git repository`);
} else if (!hasGitRepo) {
console.log(` ⚠ Not a git repository. Run 'fn init --git' to auto-initialize one.`);
}
// Add local Fusion/Pi storage directories to .gitignore // Add local Fusion/Pi storage directories to .gitignore
await addLocalStorageToGitignore(cwd); await addLocalStorageToGitignore(cwd);
await warnIfQmdMissing(); await warnIfQmdMissing();
@@ -214,6 +225,55 @@ async function addLocalStorageToGitignore(cwd: string): Promise<void> {
} }
} }
async function initializeGitRepo(cwd: string): Promise<void> {
await execAsync("git init", { cwd, timeout: 10_000 });
try {
const { stdout } = await execAsync("git symbolic-ref --quiet --short HEAD", {
cwd,
timeout: 10_000,
});
if (stdout.trim() !== "main") {
await execAsync("git checkout -b main", { cwd, timeout: 10_000 });
}
} catch {
// Older git versions or detached/unborn states may fail symbolic-ref.
// Best-effort: create/switch to main.
try {
await execAsync("git checkout -b main", { cwd, timeout: 10_000 });
} catch {
await execAsync("git checkout main", { cwd, timeout: 10_000 });
}
}
await ensureGitConfig(cwd, "user.name", "Fusion");
await ensureGitConfig(cwd, "user.email", "noreply@runfusion.ai");
const gitkeepPath = join(cwd, ".gitkeep");
if (!existsSync(gitkeepPath)) {
writeFileSync(gitkeepPath, "\n");
}
await execAsync("git add .gitkeep", { cwd, timeout: 10_000 });
await execAsync('git commit --allow-empty -m "chore: initial commit"', {
cwd,
timeout: 10_000,
});
}
async function ensureGitConfig(cwd: string, key: string, value: string): Promise<void> {
try {
const { stdout } = await execAsync(`git config --get ${key}`, { cwd, timeout: 10_000 });
if (stdout.trim().length > 0) {
return;
}
} catch {
// Missing config; set a local default.
}
await execAsync(`git config ${key} "${value}"`, { cwd, timeout: 10_000 });
}
async function warnIfQmdMissing(): Promise<void> { async function warnIfQmdMissing(): Promise<void> {
if (await isQmdAvailable()) { if (await isQmdAvailable()) {
console.log(` ✓ qmd available for memory search`); console.log(` ✓ qmd available for memory search`);