feat(FN-2622): merge fusion/fn-2622
This commit is contained in:
5
.changeset/auto-git-init.md
Normal file
5
.changeset/auto-git-init.md
Normal 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.
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
Reference in New Issue
Block a user