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
26 changes: 26 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions packages/core-engine/src/services/source-authority-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,25 @@ export class SourceAuthorityService {
continue;
}

const lockedCommit = this.readLockedCommitSha(lock.revision);
if (source.kind === "git" && lockedCommit) {
try {
const remoteCommit = await this.options.checkoutService.readGitRemoteHeadCommit(
source.locator,
lock.originBranch ? { branch: lock.originBranch } : {},
);
if (remoteCommit && remoteCommit === lockedCommit) {
updated.push(this.emptyUpdateResult(sourceId));
continue;
}
} catch (error) {
warnings.push({
code: "SOURCE_REMOTE_COMMIT_CHECK_FAILED",
message: `Unable to verify remote commit for '${sourceId}': ${String(error)}`,
});
}
}

const tempCheckoutPath = path.join(
this.options.stateStore.rootPath,
"source",
Expand Down Expand Up @@ -534,6 +553,10 @@ export class SourceAuthorityService {
return kind;
}

private readLockedCommitSha(revision: SourceRevision): string | undefined {
return "commit" in revision ? revision.commit : undefined;
}

private emptyUpdateResult(sourceId: string): SourceUpdateResultItem {
return {
sourceId,
Expand Down
36 changes: 36 additions & 0 deletions packages/core-engine/src/services/source-checkout-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,42 @@ export class SourceCheckoutService {
}, snapshot.warnings);
}

async readGitRemoteHeadCommit(
locator: string,
options: { branch?: string } = {},
): Promise<string | undefined> {
if (!(await isGitAvailable())) {
return undefined;
}

const parseCommitSha = (raw: string): string | undefined => {
const line = raw
.split(/\r?\n/)
.map((entry) => entry.trim())
.find((entry) => entry.length > 0);
const sha = line?.split(/\s+/)[0]?.trim();
return sha && /^[0-9a-f]{40}$/i.test(sha) ? sha : undefined;
};

if (options.branch) {
const branchRef = `refs/heads/${options.branch}`;
const branchOutput = await withNetworkRetries(
() => git(["ls-remote", locator, branchRef], { timeoutMs: 30_000 }),
{ attempts: 2 },
);
const branchCommit = parseCommitSha(branchOutput);
if (branchCommit) {
return branchCommit;
}
}

const headOutput = await withNetworkRetries(
() => git(["ls-remote", locator, "HEAD"], { timeoutMs: 30_000 }),
{ attempts: 2 },
);
return parseCommitSha(headOutput);
}

async normalizeLocator(locator: string): Promise<string> {
const trimmed = locator.trim();

Expand Down
82 changes: 81 additions & 1 deletion packages/core-engine/src/tests/source-authority-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { StateStore } from "@skill-flow/storage/state-store";
import { InventoryService } from "../services/inventory-service.js";
import { SourceAuthorityService } from "../services/source-authority-service.js";
Expand Down Expand Up @@ -216,6 +216,86 @@ describe.sequential("SourceAuthorityService", () => {
]);
});

test("updateSources skips git refresh when remote commit is unchanged", async () => {
const stateStore = new StateStore(sandbox.stateRoot);
await stateStore.init();
const checkoutService = new SourceCheckoutService({
sourceRoot: path.join(sandbox.stateRoot, "source"),
inventoryService: new InventoryService(),
});
const service = new SourceAuthorityService({
stateStore,
checkoutService,
});

const preparedCheckoutPath = path.join(
sandbox.stateRoot,
"source",
"git",
".prepared-git-unchanged",
);
await fs.mkdir(path.join(preparedCheckoutPath, "skills", "one"), { recursive: true });
await fs.writeFile(
path.join(preparedCheckoutPath, "skills", "one", "SKILL.md"),
skillDoc("one", "One."),
"utf8",
);
const committed = await service.commitPreparedSource({
preparedCheckout: {
locator: "https://github.com/acme/skills.git",
displayName: "Skills",
kind: "git",
sourceId: "git-unchanged",
checkoutPath: preparedCheckoutPath,
leafs: [{
id: "git-unchanged:skills/one",
sourceId: "git-unchanged",
name: "one",
linkName: "one",
title: "one",
description: "One.",
relativePath: "skills/one",
absolutePath: path.join(preparedCheckoutPath, "skills", "one"),
skillFilePath: path.join(preparedCheckoutPath, "skills", "one", "SKILL.md"),
contentHash: "hash-one",
diagnostics: [],
valid: true,
}],
invalidLeafs: [],
commitSha: "same-sha",
},
});
expect(committed.ok).toBe(true);
if (!committed.ok) {
return;
}

checkoutService.readGitRemoteHeadCommit = vi.fn(async () => "same-sha");
let prepareCalled = false;
checkoutService.prepareSourceCheckout = vi.fn(async () => {
prepareCalled = true;
throw new Error("prepareSourceCheckout should not be called when commit is unchanged");
});

const updated = await service.updateSources(["git-unchanged"]);

expect(updated.ok).toBe(true);
if (!updated.ok) {
return;
}
expect(prepareCalled).toBe(false);
expect(updated.data.updated).toEqual([
expect.objectContaining({
sourceId: "git-unchanged",
changed: false,
}),
]);
const state = await stateStore.readState();
expect(state.lockFile.sources["git-unchanged"]?.leafIds).toEqual([
"git-unchanged:skills/one",
]);
});

test("updateSources keeps successful groups when another group fails mid-batch", async () => {
const goodRepo = await createRepo(sandbox.sandboxRoot, {
"skills/good/SKILL.md": skillDoc("good", "Good."),
Expand Down
Loading