Skip to content
Open
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
63 changes: 61 additions & 2 deletions src/qmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ async function showStatus(): Promise<void> {
closeDb();
}

async function updateCollections(): Promise<void> {
async function updateCollections(pullFirst: boolean = false): Promise<void> {
const db = getDb();
// Collections are defined in YAML; no duplicate cleanup needed.

Expand All @@ -434,6 +434,65 @@ async function updateCollections(): Promise<void> {
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) {
Expand Down Expand Up @@ -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":
Expand Down
74 changes: 74 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,40 @@ async function runQmd(
return { stdout, stderr, exitCode };
}

async function runCommand(
command: string,
args: string[],
cwd: string,
env: Record<string, string> = {}
): 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<string>((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<string>((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<number>((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++;
Expand Down Expand Up @@ -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", () => {
Expand Down