mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(core): keep session-commit registration for an absolute cd into the same repo
`cdTargetMayChangeRepo` returned true for ANY target starting with '/', with no reference to the shell's cwd. The product-recommended spelling `cd /abs/path/to/this/repo && git commit -m "..."` therefore latched `cwdShifted`, `attributableInCwd` went false, `trackSessionCommit` never ran, `sessionCommitShas` stayed empty, and the agent's own next `git commit --amend` was hard-blocked by the amend guard. Resolve an absolute target against the shell cwd and treat the cwd itself or a subdirectory of it as in-repo. Sibling and out-of-tree targets still shift, including the `<repo>-sibling` case that a bare `startsWith` would wrongly admit. Relative targets are untouched, and resolution stays static (`path.resolve`) — no `git rev-parse` in the walk. `gitCommitContext`, `isAmendCommit` and `findAttributableCommitSegment` now take the cwd; all four module-private call sites thread it through. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-issue-patrol/jmvvn3bibqk
This commit is contained in:
1 parent
1ccf0d43f3
commit
a0a7364917
2 files changed
+98
-16
No files matched your search
@@ -462,6 +462,59 @@ describe.skipIf(process.platform === 'win32')(
|
||||
await expectOtherRepoCommitBlocked(runShellCommand);
|
||||
});
|
||||
|
||||
it('registers a commit after an absolute `cd` into the same repo root (issue #12514)', async () => {
|
||||
// #12514-A: `cdTargetMayChangeRepo` returns true for ANY target starting
|
||||
// with '/', with no reference to the shell cwd. So the
|
||||
// product-recommended spelling `cd /abs/path/to/this/repo && git commit`
|
||||
// latched `cwdShifted`, `attributableInCwd` went false,
|
||||
// `trackSessionCommit` never ran and `sessionCommitShas` stayed empty —
|
||||
// which is why the agent's own next `git commit --amend` was hard-blocked
|
||||
// by the guard (reported by bainmariehk, 2026-10-07).
|
||||
await expectRegisters(`cd ${repoDir} && ${COMMIT_FEATURE}`, false);
|
||||
});
|
||||
|
||||
it('registers a commit after an absolute `cd` into a subdirectory of the same repo (issue #12514)', async () => {
|
||||
// #12514-A, subdirectory arm: an absolute target inside the cwd tree must
|
||||
// count as in-repo, not only an exact-equal path. `git` resolves the
|
||||
// toplevel from any subdirectory, so the commit still lands in our repo
|
||||
// and must still be registered (and carry the trailer).
|
||||
const subDir = path.join(repoDir, 'sub');
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
const preHead = headSha();
|
||||
fs.writeFileSync(path.join(subDir, 'feature.txt'), 'work\n');
|
||||
await runShellCommand(
|
||||
`cd ${subDir} && git add feature.txt && git commit -m "feature"`,
|
||||
);
|
||||
expectFeatureCommitLanded(preHead);
|
||||
expect(amendVerdict()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the cwd-shift latch for an absolute `cd` into a sibling directory that shares the repo path prefix (issue #12514)', async () => {
|
||||
// Guards the obvious wrong fix: a bare `target.startsWith(cwd)` without a
|
||||
// separator boundary. `<repo>-sibling` shares the repo path as a *string*
|
||||
// prefix but is a different repository, so admitting it would stamp our
|
||||
// trailer onto a foreign repo's commit — the corruption `cwdShifted`
|
||||
// exists to prevent.
|
||||
const siblingDir = `${repoDir}-sibling`;
|
||||
tempDirs.push(siblingDir);
|
||||
fs.mkdirSync(siblingDir, { recursive: true });
|
||||
initRepo(siblingDir, { subject: 'sibling seed' });
|
||||
fs.writeFileSync(path.join(siblingDir, 'work.txt'), 'sibling work\n');
|
||||
const repoHeadBefore = headSha();
|
||||
|
||||
await runShellCommand(
|
||||
`cd ${siblingDir} && git add work.txt && git commit -m "sibling work"`,
|
||||
);
|
||||
|
||||
// The commit really landed — in the SIBLING repository.
|
||||
expect(lastLog('%s', siblingDir)).toBe('sibling work');
|
||||
expect(headSha()).toBe(repoHeadBefore);
|
||||
expect(lastLog('%B', siblingDir)).not.toContain(
|
||||
'Co-authored-by: Qwen-Coder',
|
||||
);
|
||||
expect(amendVerdict()?.blocked).toBe(true);
|
||||
});
|
||||
|
||||
// R1-11: `COMMIT_RECOGNITION_LEADING_NOISE` feeds the one helper both
|
||||
// recognisers call, so a row per token pins registration and the trailer
|
||||
// rewrite at once. Each token leads the segment carrying `git commit`
|
||||
|
||||
@@ -762,7 +762,10 @@ function programBasename(program: string): string {
|
||||
* attribution. The trade-off matches the wrong-repo guard's intent
|
||||
* (better miss than corrupt unrelated repos).
|
||||
*/
|
||||
function gitCommitContext(command: string): {
|
||||
function gitCommitContext(
|
||||
command: string,
|
||||
cwd: string,
|
||||
): {
|
||||
hasCommit: boolean;
|
||||
attributableInCwd: boolean;
|
||||
} {
|
||||
@@ -793,7 +796,7 @@ function gitCommitContext(command: string): {
|
||||
// --show-toplevel` after the cd, which would require a synchronous
|
||||
// fs/exec call that the rest of this walk avoids — the heuristic
|
||||
// covers the common case and stays conservative on the rest.
|
||||
if (!hasCommit && cdTargetMayChangeRepo(tokens)) cwdShifted = true;
|
||||
if (!hasCommit && cdTargetMayChangeRepo(tokens, cwd)) cwdShifted = true;
|
||||
continue;
|
||||
}
|
||||
if (program === 'popd') {
|
||||
@@ -864,20 +867,37 @@ function parseGhInvocation(tokens: string[]): string[] {
|
||||
* whether a subsequent `git commit` in the same chain is still
|
||||
* attributable in our cwd.
|
||||
*
|
||||
* Returns true (conservative — assume shift) when the target is
|
||||
* absolute, escapes upward (`..`), goes to `$HOME` / `~`, contains an
|
||||
* env-var (we can't resolve it statically), or is missing entirely
|
||||
* (`cd` alone goes to `$HOME`). Plain relative paths like `cd src`,
|
||||
* `cd ./packages/foo`, or `cd subdir/nested` are treated as in-repo.
|
||||
* Returns true (conservative — assume shift) when the target escapes
|
||||
* upward (`..`), goes to `$HOME` / `~`, contains an env-var (we can't
|
||||
* resolve it statically), or is missing entirely (`cd` alone goes to
|
||||
* `$HOME`). Plain relative paths like `cd src`, `cd ./packages/foo`,
|
||||
* or `cd subdir/nested` are treated as in-repo.
|
||||
*
|
||||
* An absolute target is resolved against `cwd` (the directory the shell
|
||||
* command runs in, #12514-A): landing on that directory or in a
|
||||
* subdirectory of it cannot change the repository, so the very common
|
||||
* `cd /abs/path/to/this/repo && git commit -m "..."` spelling keeps
|
||||
* attribution. Anything else absolute — a sibling, an unrelated tree —
|
||||
* still shifts. Resolution is static (`path.resolve`) on purpose: the
|
||||
* walk must not spawn `git rev-parse` (see `gitCommitContext`), and a
|
||||
* symlinked spelling that resolves elsewhere is still caught by the
|
||||
* equality/prefix test because both sides go through the same `cwd`.
|
||||
*/
|
||||
function cdTargetMayChangeRepo(tokens: string[]): boolean {
|
||||
function cdTargetMayChangeRepo(tokens: string[], cwd: string): boolean {
|
||||
// tokens[0] is 'cd'. The next non-flag token is the target.
|
||||
let i = 1;
|
||||
while (i < tokens.length && tokens[i]!.startsWith('-')) i++;
|
||||
const target = tokens[i];
|
||||
// `cd` with no argument goes to $HOME.
|
||||
if (target === undefined) return true;
|
||||
if (target.startsWith('/')) return true;
|
||||
if (target.startsWith('/')) {
|
||||
// Same directory, or a subdirectory of it: still this repo. The
|
||||
// `path.sep` in the prefix test is load-bearing — `<repo>-sibling`
|
||||
// shares the string prefix but is a different repository.
|
||||
const root = path.resolve(cwd);
|
||||
const resolved = path.resolve(cwd, target);
|
||||
return !(resolved === root || resolved.startsWith(root + path.sep));
|
||||
}
|
||||
if (target.startsWith('~')) return true;
|
||||
// Env-var reference (e.g. `$HOME`, `$REPO`) — can't resolve here.
|
||||
if (target.includes('$')) return true;
|
||||
@@ -910,14 +930,14 @@ function cdTargetMayChangeRepo(tokens: string[]): boolean {
|
||||
* must not flip the diff range for the second (fresh) commit, since
|
||||
* `preHead` would be the inner repo's SHA there, not ours.
|
||||
*/
|
||||
function isAmendCommit(command: string): boolean {
|
||||
function isAmendCommit(command: string, cwd: string): boolean {
|
||||
let cwdShifted = false;
|
||||
for (const sub of splitCommands(command)) {
|
||||
const tokens = tokeniseSegment(sub);
|
||||
if (!tokens || tokens.length === 0) continue;
|
||||
const program = tokens[0]!;
|
||||
if (program === 'cd' || program === 'pushd') {
|
||||
if (!cwdShifted && cdTargetMayChangeRepo(tokens)) cwdShifted = true;
|
||||
if (!cwdShifted && cdTargetMayChangeRepo(tokens, cwd)) cwdShifted = true;
|
||||
continue;
|
||||
}
|
||||
if (program === 'popd') {
|
||||
@@ -985,6 +1005,7 @@ function isAmendCommit(command: string): boolean {
|
||||
*/
|
||||
function findAttributableCommitSegment(
|
||||
command: string,
|
||||
cwd: string,
|
||||
): { start: number; end: number } | null {
|
||||
let cursor = 0;
|
||||
let cwdShifted = false;
|
||||
@@ -1013,7 +1034,7 @@ function findAttributableCommitSegment(
|
||||
// Mirror gitCommitContext's cd/pushd heuristic: relative paths
|
||||
// that don't escape upward are treated as in-repo, so
|
||||
// `cd subdir && git commit ...` still finds the segment.
|
||||
if (!cwdShifted && cdTargetMayChangeRepo(looseTokens)) {
|
||||
if (!cwdShifted && cdTargetMayChangeRepo(looseTokens, cwd)) {
|
||||
cwdShifted = true;
|
||||
}
|
||||
continue;
|
||||
@@ -2502,7 +2523,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
// text like `echo "git commit"` doesn't trigger snapshot/notes,
|
||||
// and so attribution still runs after a `git commit && cd ..`
|
||||
// chain (which would have failed an "any cd anywhere" gate).
|
||||
const commitCtx = gitCommitContext(strippedCommand);
|
||||
const commitCtx = gitCommitContext(strippedCommand, cwd);
|
||||
// Capture preHead only when the commit will actually be
|
||||
// attributed in our cwd: that's the only consumer (the
|
||||
// `attributableInCwd` branch below feeds preHead into
|
||||
@@ -3065,7 +3086,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
// — `preHead` was captured synchronously before spawn and is
|
||||
// the pre-amend SHA, so this range captures only the amend
|
||||
// delta.
|
||||
const isAmend = isAmendCommit(strippedCommand);
|
||||
const isAmend = isAmendCommit(strippedCommand, cwd);
|
||||
attributionWarning = await this.attachCommitAttribution(
|
||||
cwd,
|
||||
preHead,
|
||||
@@ -4225,7 +4246,12 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
// Use the broader `hasCommit` flag rather than `attributableInCwd`:
|
||||
// `cd /elsewhere && git commit` should still be refused even
|
||||
// though we wouldn't attribute it.
|
||||
if (gitCommitContext(strippedCommand).hasCommit) {
|
||||
if (
|
||||
gitCommitContext(
|
||||
strippedCommand,
|
||||
this.params.directory || this.config.getTargetDir(),
|
||||
).hasCommit
|
||||
) {
|
||||
return {
|
||||
llmContent:
|
||||
'Refusing to run `git commit` in background mode: AI-attribution notes ' +
|
||||
@@ -5388,7 +5414,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
// (with the trailer mid-string) back to the executor. The
|
||||
// in-cwd check is what we want here: only inject the
|
||||
// trailer when we're confident the commit lands in our cwd.
|
||||
const segmentRange = findAttributableCommitSegment(command);
|
||||
const segmentRange = findAttributableCommitSegment(
|
||||
command,
|
||||
this.params.directory || this.config.getTargetDir(),
|
||||
);
|
||||
if (!segmentRange) {
|
||||
return command;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user