diff --git a/src/qmd.ts b/src/qmd.ts index 244578f8..7466a7ee 100755 --- a/src/qmd.ts +++ b/src/qmd.ts @@ -411,7 +411,7 @@ async function showStatus(): Promise { closeDb(); } -async function updateCollections(): Promise { +async function updateCollections(pullFirst: boolean = false): Promise { const db = getDb(); // Collections are defined in YAML; no duplicate cleanup needed. @@ -434,6 +434,65 @@ async function updateCollections(): Promise { if (!col) continue; console.log(`${c.cyan}[${i + 1}/${collections.length}]${c.reset} ${c.bold}${col.name}${c.reset} ${c.dim}(${col.glob_pattern})${c.reset}`); + if (pullFirst) { + let isGitRepo = false; + try { + const insideWorkTree = execSync("git rev-parse --is-inside-work-tree", { + cwd: col.pwd, + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf-8", + }).trim(); + isGitRepo = insideWorkTree === "true"; + } catch { + isGitRepo = false; + } + + if (!isGitRepo) { + console.log(`${c.dim} Skipping git pull: not a git repository${c.reset}`); + } else { + let hasUpstream = true; + try { + execSync("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { + cwd: col.pwd, + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf-8", + }).trim(); + } catch { + hasUpstream = false; + } + + if (!hasUpstream) { + console.log(`${c.dim} Skipping git pull: no upstream tracking branch${c.reset}`); + } else { + console.log(`${c.dim} Running git pull --ff-only${c.reset}`); + try { + const output = execSync("git pull --ff-only", { + cwd: col.pwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); + if (output.trim()) { + console.log(output.trim().split('\n').map(l => ` ${l}`).join('\n')); + } + } catch (err: any) { + const output = err?.stdout ? String(err.stdout).trim() : ""; + const errorOutput = err?.stderr ? String(err.stderr).trim() : ""; + + if (output) { + console.log(output.split('\n').map((l: string) => ` ${l}`).join('\n')); + } + if (errorOutput) { + console.log(errorOutput.split('\n').map((l: string) => ` ${l}`).join('\n')); + } + + const exitCode = typeof err?.status === "number" ? err.status : 1; + console.log(`${c.yellow}✗ Git pull failed with exit code ${exitCode}${c.reset}`); + process.exit(exitCode); + } + } + } + } + // Execute custom update command if specified in YAML const yamlCol = getCollectionFromYaml(col.name); if (yamlCol?.update) { @@ -2422,7 +2481,7 @@ if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsW break; case "update": - await updateCollections(); + await updateCollections(!!cli.values.pull); break; case "embed": diff --git a/test/cli.test.ts b/test/cli.test.ts index b723e7de..bd92ec52 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -76,6 +76,40 @@ async function runQmd( return { stdout, stderr, exitCode }; } +async function runCommand( + command: string, + args: string[], + cwd: string, + env: Record = {} +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = spawn(command, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const stdoutPromise = new Promise((resolve, reject) => { + let data = ""; + proc.stdout?.on("data", (chunk: Buffer) => { data += chunk.toString(); }); + proc.once("error", reject); + proc.stdout?.once("end", () => resolve(data)); + }); + const stderrPromise = new Promise((resolve, reject) => { + let data = ""; + proc.stderr?.on("data", (chunk: Buffer) => { data += chunk.toString(); }); + proc.once("error", reject); + proc.stderr?.once("end", () => resolve(data)); + }); + const exitCode = await new Promise((resolve, reject) => { + proc.once("error", reject); + proc.on("close", (code) => resolve(code ?? 1)); + }); + const stdout = await stdoutPromise; + const stderr = await stderrPromise; + + return { stdout, stderr, exitCode }; +} + // Get a fresh database path for isolated tests function getFreshDbPath(): string { testCounter++; @@ -395,6 +429,46 @@ describe("CLI Update Command", () => { expect(exitCode).toBe(0); expect(stdout).toContain("Updating"); }); + + test("supports --pull and skips repos without upstream", async () => { + const env = await createIsolatedTestEnv("update-pull"); + const repoDir = join(testDir, `update-pull-repo-${Date.now()}`); + await mkdir(repoDir, { recursive: true }); + await writeFile(join(repoDir, "README.md"), "# pull test\n"); + + const init = await runCommand("git", ["init"], repoDir); + expect(init.exitCode).toBe(0); + + const add = await runCommand("git", ["add", "."], repoDir); + expect(add.exitCode).toBe(0); + + const commit = await runCommand( + "git", + ["commit", "-m", "initial commit"], + repoDir, + { + GIT_AUTHOR_NAME: "QMD Test", + GIT_AUTHOR_EMAIL: "qmd-test@example.com", + GIT_COMMITTER_NAME: "QMD Test", + GIT_COMMITTER_EMAIL: "qmd-test@example.com", + } + ); + expect(commit.exitCode).toBe(0); + + const addCollection = await runQmd( + ["collection", "add", repoDir, "--name", "pull-test"], + { dbPath: env.dbPath, configDir: env.configDir } + ); + expect(addCollection.exitCode).toBe(0); + + const { stdout, exitCode } = await runQmd( + ["update", "--pull"], + { dbPath: env.dbPath, configDir: env.configDir } + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Skipping git pull: no upstream tracking branch"); + }); }); describe("CLI Add-Context Command", () => {