fix(attribution): preserve unstaged AI edits across cleanup branches

uxU5 + uxVQ + uxUO (Copilot): every cleanup branch in
attachCommitAttribution that called clearAttributions(true) was
wholesale-erasing pending AI edits for files the user never staged
in this commit. Reviewer scenarios:
- multi-commit chain (`commit a && commit b`) bails out without
  writing a note, but unstaged edits to file Z (touched by neither
  commit) get cleared along with the chain's committed files.
- attribution toggle off: same — toggling the flag wipes pending
  unstaged work.
- analysis failure (shallow clone, --amend without reflog, partial
  diff failure): the finally-block fallback wholesale-cleared
  every pending file, consuming unrelated AI edits.
- 0%-AI commit: when no file in the commit was AI-touched,
  generateNotePayload was emitting an "0% AI" note attached to a
  commit that legitimately had no AI involvement — actively
  misleading metadata.

Add `noteCommitWithoutClearing()` to the service: snapshots the
prompt counter as the new "at last commit" but leaves the per-file
map alone. Use it in the multi-commit, no-tracked-edits,
toggle-off, and analysis-failure paths. The committed-files
partial-clear (clearAttributedFiles) still runs in the success
path. The 0%-AI no-match case now skips the note write entirely.
This commit is contained in:
wenshao 2026-05-06 07:23:47 +08:00
parent 325a12c3c1
commit 1ece87438f
2 changed files with 47 additions and 11 deletions

View file

@ -329,6 +329,20 @@ export class CommitAttributionService {
}
}
/**
* Snapshot the prompt counter as the new "last commit" without
* clearing per-file attribution. Used when a commit landed but we
* can't reliably determine which files were in it (multi-commit
* chain we won't write a note for, attribution toggle off, diff
* analysis failed). Wholesale-clearing in those branches would
* silently wipe pending AI edits for *unrelated* files the user
* didn't stage a worse failure mode than the small risk of
* stale per-file state for files that did just land.
*/
noteCommitWithoutClearing(): void {
this.promptCountAtLastCommit = this.promptCount;
}
/**
* Resolve a set of repo-relative file paths to the canonical absolute
* keys actually stored in the attribution map. Used by cleanup to

View file

@ -1914,7 +1914,13 @@ export class ShellToolInvocation extends BaseToolInvocation<
: `multi-commit shell command (${commitCount} commits since ` +
`${preHead ? preHead.slice(0, 12) : 'repo root'})`;
debugLogger.warn(`Refusing AI attribution: ${reason}.`);
attributionService.clearAttributions(true);
// Snapshot the prompt counter but do NOT clear per-file
// attributions: in a `commit a && commit b` chain, the user
// may have unstaged AI edits to files that appeared in NEITHER
// commit. Wholesale-clearing here would erase those even
// though the rest of the flow is built to preserve unstaged
// entries across partial commits.
attributionService.noteCommitWithoutClearing();
return null;
}
@ -1924,17 +1930,19 @@ export class ShellToolInvocation extends BaseToolInvocation<
// "at last commit" so a later `gh pr create` doesn't report an
// inflated N-shotted count spanning multiple commits.
if (!attributionService.hasAttributions()) {
attributionService.clearAttributions(true);
attributionService.noteCommitWithoutClearing();
return null;
}
const gitCoAuthorSettings = this.config.getGitCoAuthor();
if (!gitCoAuthorSettings.commit) {
// Commit succeeded but attribution is disabled. Still snapshot
// the prompt counters as "at last commit" so the next commit
// starts a fresh window — otherwise the user would carry stale
// counts forward forever.
attributionService.clearAttributions(true);
// Commit succeeded but attribution is disabled. Snapshot the
// prompt counters as "at last commit" but leave per-file
// attributions alone — a wholesale clear here would lose the
// user's pending unstaged AI work just because they toggled
// attribution off, which is a much harsher contract than the
// toggle name suggests.
attributionService.noteCommitWithoutClearing();
return null;
}
@ -1994,6 +2002,16 @@ export class ShellToolInvocation extends BaseToolInvocation<
canonicalBase,
);
// No file in this commit was AI-touched in the current session.
// Writing a note anyway would emit an all-zero "0% AI" payload
// attached to a commit that legitimately had no AI involvement
// — actively misleading. Skip the note; the partial clear in
// the finally block is a no-op (empty set) so unrelated pending
// attributions stay tracked for a later commit.
if (committedAbsolutePaths.size === 0) {
return null;
}
const note = attributionService.generateNotePayload(
stagedInfo,
baseDir,
@ -2063,13 +2081,17 @@ export class ShellToolInvocation extends BaseToolInvocation<
// Partial clear: only drop tracking for the files that actually
// landed in this commit. Files the AI edited but the user
// omitted from `git add` stay pending for a later commit.
// If we never determined the committed set (early failure in
// getCommittedFileInfo), fall back to a full clear so we don't
// leak stale per-file state — counters still get snapshotted.
// If we never determined the committed set (analysis failure:
// shallow clone, --amend without reflog, partial diff failure,
// exception), DO NOT wholesale-clear: that would erase pending
// AI edits for files the user never staged in this commit. The
// small risk is stale per-file state for the just-committed
// file (re-attributed if it appears in a future commit) — much
// less harmful than losing unrelated unstaged work.
if (committedAbsolutePaths) {
attributionService.clearAttributedFiles(committedAbsolutePaths);
} else {
attributionService.clearAttributions(true);
attributionService.noteCommitWithoutClearing();
}
}
return warning;