fix(commit): pair staged lock files with the split-plan commit that owns them
git_overview hides EXCLUDED_LOCK_FILES from the model so lock files never drive split decisions, but runSplitCommit then re-fetched the raw staged set and rejected any plan that failed to enumerate them, aborting `omp commit` with "Split commit plan missing staged files: <lockfile>". Skipping the validator would have masked a real drop — the executor resets the index and only re-stages files listed in each commit group. Introduce packages/coding-agent/src/commit/agentic/lock-files.ts with a LOCK_FILE_MANIFESTS map and an assignLockFilesToPlan helper that attaches each orphaned lock file to (1) the commit group touching a sibling manifest in the same directory, (2) any commit group touching a matching manifest, or (3) the last commit group. git-overview.ts imports EXCLUDED_LOCK_FILES from the shared module so the filter and the pairing table stay in sync. Fixes #4632
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { assignLockFilesToPlan, EXCLUDED_LOCK_FILES, LOCK_FILE_MANIFESTS } from "../src/commit/agentic/lock-files";
|
||||
import type { SplitCommitPlan } from "../src/commit/agentic/state";
|
||||
|
||||
/**
|
||||
* Contract: staged lock files that `git_overview` hid from the model must be
|
||||
* paired with the split-plan commit touching their sibling manifest, so the
|
||||
* validator in `runSplitCommit` (packages/coding-agent/src/commit/agentic/index.ts)
|
||||
* no longer rejects an otherwise valid plan with
|
||||
* `Split commit plan missing staged files: <lockfile>`. See issue #4632.
|
||||
*/
|
||||
|
||||
function plan(...commits: Array<{ paths: string[] }>): SplitCommitPlan {
|
||||
return {
|
||||
warnings: [],
|
||||
commits: commits.map(commit => ({
|
||||
changes: commit.paths.map(path => ({ path, hunks: { type: "all" } })),
|
||||
type: "chore",
|
||||
scope: null,
|
||||
summary: "test",
|
||||
details: [],
|
||||
issueRefs: [],
|
||||
dependencies: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function pathsOf(target: SplitCommitPlan, index: number): string[] {
|
||||
return target.commits[index].changes.map(change => change.path);
|
||||
}
|
||||
|
||||
describe("assignLockFilesToPlan", () => {
|
||||
it("attaches a lock file to the commit group that touches its sibling manifest", () => {
|
||||
const target = plan({ paths: ["pyproject.toml"] }, { paths: ["src/foo.py"] });
|
||||
assignLockFilesToPlan(target, ["pyproject.toml", "src/foo.py", "uv.lock"]);
|
||||
expect(pathsOf(target, 0)).toEqual(["pyproject.toml", "uv.lock"]);
|
||||
expect(pathsOf(target, 1)).toEqual(["src/foo.py"]);
|
||||
});
|
||||
|
||||
it("prefers a sibling manifest in the same directory over a matching manifest elsewhere", () => {
|
||||
const target = plan({ paths: ["packages/root/package.json"] }, { paths: ["packages/child/package.json"] });
|
||||
assignLockFilesToPlan(target, [
|
||||
"packages/root/package.json",
|
||||
"packages/child/package.json",
|
||||
"packages/child/package-lock.json",
|
||||
]);
|
||||
expect(pathsOf(target, 0)).toEqual(["packages/root/package.json"]);
|
||||
expect(pathsOf(target, 1)).toEqual(["packages/child/package.json", "packages/child/package-lock.json"]);
|
||||
});
|
||||
|
||||
it("falls back to any matching manifest when no sibling in the lock file's directory is planned", () => {
|
||||
const target = plan({ paths: ["docs/README.md"] }, { paths: ["crates/thing/Cargo.toml"] });
|
||||
assignLockFilesToPlan(target, ["docs/README.md", "crates/thing/Cargo.toml", "Cargo.lock"]);
|
||||
expect(pathsOf(target, 0)).toEqual(["docs/README.md"]);
|
||||
expect(pathsOf(target, 1)).toEqual(["crates/thing/Cargo.toml", "Cargo.lock"]);
|
||||
});
|
||||
|
||||
it("falls back to the last commit when no manifest sibling is planned", () => {
|
||||
const target = plan({ paths: ["src/a.ts"] }, { paths: ["src/b.ts"] });
|
||||
assignLockFilesToPlan(target, ["src/a.ts", "src/b.ts", "package-lock.json"]);
|
||||
expect(pathsOf(target, 0)).toEqual(["src/a.ts"]);
|
||||
expect(pathsOf(target, 1)).toEqual(["src/b.ts", "package-lock.json"]);
|
||||
});
|
||||
|
||||
it("leaves the plan unchanged when the lock file is already accounted for", () => {
|
||||
const target = plan({ paths: ["pyproject.toml", "uv.lock"] }, { paths: ["src/foo.py"] });
|
||||
assignLockFilesToPlan(target, ["pyproject.toml", "uv.lock", "src/foo.py"]);
|
||||
expect(pathsOf(target, 0)).toEqual(["pyproject.toml", "uv.lock"]);
|
||||
expect(pathsOf(target, 1)).toEqual(["src/foo.py"]);
|
||||
});
|
||||
|
||||
it("ignores staged files that are not recognized lock files", () => {
|
||||
const target = plan({ paths: ["src/a.ts"] });
|
||||
assignLockFilesToPlan(target, ["src/a.ts", "src/mystery.bin", "README.md"]);
|
||||
expect(pathsOf(target, 0)).toEqual(["src/a.ts"]);
|
||||
expect(target.commits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("no-ops on an empty plan (nothing to attach to)", () => {
|
||||
const target: SplitCommitPlan = { commits: [], warnings: [] };
|
||||
assignLockFilesToPlan(target, ["uv.lock"]);
|
||||
expect(target.commits).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps EXCLUDED_LOCK_FILES in sync with LOCK_FILE_MANIFESTS", () => {
|
||||
const manifestKeys = new Set<string>();
|
||||
for (const key in LOCK_FILE_MANIFESTS) manifestKeys.add(key);
|
||||
expect(new Set(EXCLUDED_LOCK_FILES)).toEqual(manifestKeys);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user