Skip to content

Scope autoCommit to exactly the files each write touches - #796

Open
jacobjove wants to merge 4 commits into
MrLesk:mainfrom
jacobjove:fix/scoped-autocommit
Open

Scope autoCommit to exactly the files each write touches#796
jacobjove wants to merge 4 commits into
MrLesk:mainfrom
jacobjove:fix/scoped-autocommit

Conversation

@jacobjove

@jacobjove jacobjove commented Jul 16, 2026

Copy link
Copy Markdown

Closes #795.

Every autoCommit site that staged the whole backlog directory, or finished with a bare pathspec-less git commit, now stages and commits exactly the paths it wrote.

First commitdocument_update/create, createDecision, updateTasksBulk, archiveTask, completeTask routed through addFile/stageFileMove + commitFiles. Also hardens commitFiles itself:

  • passes --no-renames, so its staged-paths check stops collapsing a delete+add pair into a rename entry naming only the destination (this silently dropped the source path, and it affected the existing archiveMilestone/renameMilestone callers)
  • guards a requested path with no git history, which is not a valid pathspec and aborted the whole commit

Second commit — the five remaining draft/task lifecycle sites (promoteDraftWithUpdates, demoteTaskWithUpdates, and the archive paths) reuse commitWrittenFile() rather than stageBacklogDirectory() + bare commitChanges().

Third commitaddAndCommitTaskFile(), behind every task create and edit. Same shape as the sites above (stages narrowly, then commits with no pathspec), plus an unscoped git reset HEAD before staging that discards a concurrent session's staged work outright rather than only sweeping it. Now goes through commitFiles(); the dead resetIndex() and commitStagedChanges() are removed. See the comment below for why this does not regress BACK-163, which introduced the reset.

Tests cover what the old shape allowed: a peer's dirty or untracked file under backlog/ must not appear in a document/decision/bulk/lifecycle commit, a peer's staged file must survive a task create/edit and stay out of its commit, plus both commitFiles cases above.

bunx tsc --noEmit clean; suite green on current main.

Split into three commits because each is a follow-up sweep over sites the previous one missed — happy to squash.

Jacob Jove and others added 3 commits July 16, 2026 16:05
… files written

document_update (and create) staged the whole backlog dir and then ran a bare
`git commit -m msg` with no pathspec, so it swept in any other dirty state
already sitting in the working tree or index — including a concurrent peer's
unreviewed plan draft, landing under a misleading "Add document" message.
createDecision, updateTasksBulk, archiveTask, and completeTask shared the same
unsafe shape (some already staged narrowly but still committed with a bare,
unscoped `git commit`). Route them all through addFile/stageFileMove +
commitFiles, which stage and commit only the exact paths each write touched.

Also hardens commitFiles: without `--no-renames`, its internal staged-paths
check collapses a staged delete+add pair (e.g. an archive move) into a single
rename entry naming only the destination, silently dropping the source path
from the commit — this affected the pre-existing archiveMilestone/renameMilestone
callers too. And guards against a requested path with no git history at all
(e.g. created and archived in the same operation while autoCommit was off),
which isn't a valid pathspec and would otherwise abort the whole commit.

Five more call sites (promoteDraftWithUpdates, demoteTaskWithUpdates,
archiveDraft, promoteDraft, demoteTask) share the same unsafe pattern and are
left for a follow-up to keep this change reviewable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ed files

Follow-up to the document/decision/bulk-task/archive auto-commit scoping fix.
The five remaining draft/task lifecycle auto-commit sites in Core still used the
unsafe stageBacklogDirectory() + bare commitChanges() pattern, which stages the
whole backlog directory and commits with no pathspec. That sweeps a peer's
untracked file living inside backlog/ and any unrelated pre-staged change into
the commit.

Each site now commits scoped to exactly the paths it moved, reusing the existing
commitWrittenFile() helper (stage the rename-or-add, then commitFiles() with the
exact paths):

- promoteDraftWithUpdates / demoteTaskWithUpdates: use the original draft/task
  filePath (already in scope) plus the saved path.
- archiveDraft: resolve the source path via getDraftPath() before fs.archiveDraft
  unlinks it; derive the archive destination from the now-public
  FileSystem.getArchiveDraftsDir().
- promoteDraft (single-shot): thread the previous and saved paths out of the
  create-lock closure (savedPath was previously discarded).
- demoteTask (single-shot): reimplement inline (mirroring promoteDraft) so the
  moved paths are in scope, rather than delegating to fs.demoteTask, which
  returns only a boolean.

Adds src/test/draft-lifecycle-autocommit-scope.test.ts: one regression test per
site (git fixture + unrelated staged deletion + peer untracked file inside
backlog/), asserting the commit excludes both and the unrelated dirty state
survives. Verified RED (old pattern swept UNRELATED.txt and peer-plan.md) then
GREEN. Full affected suites (147 tests) and bunx tsc --noEmit pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
addAndCommitTaskFile() ran an unscoped `git reset HEAD` before staging its
own file, then committed the whole index. Single-user that is invisible, but
with a concurrent session in the repo it silently discards work: a peer that
has staged a hunk and not yet committed loses its index, and its subsequent
`git commit` lands empty or short with nothing announcing the loss.

Stage the task file and commit it through commitFiles(), which pathspecs the
commit to exactly that file. The pathspec is what enforces the isolation the
reset was reaching for, without touching anyone else's staged work.

This preserves BACK-163's intent, which introduced the reset: its acceptance
criteria were that task edit "should only stage and commit the modified task
file" and "should not commit any other staged or unstaged changes in the
repository". The reset was the mechanism, not the goal, and a scoped commit
reaches the goal without the collateral damage. It also completes the
auto-commit scoping pass (MrLesk#795/MrLesk#796), which scoped every add and commit but
left this reset.

resetIndex() and commitStagedChanges() have no callers left and are removed;
the scoped resetPaths() sibling is untouched and remains the convention.

Verified: new regression test fails on the old code (the peer's staged path
comes back empty) and passes now; full suite 1719 pass / 0 fail; and the
rebuilt CLI binary keeps a peer's staged hunk through a `backlog task edit`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jacobjove

Copy link
Copy Markdown
Author

Pushed a third commit, for one more site in this family.

addAndCommitTaskFile(), the auto-commit behind every task create and edit, has the shape #795 describes: it stages narrowly, then finishes with a pathspec-less commit. It also runs an unscoped git reset HEAD before staging, which does more than sweep. With a second session working in the repo it discards that session's staged work outright, and their next commit lands empty or short with nothing announcing the loss. We hit this one directly: a peer's backlog task edit cleared an index between our git apply --cached and our git commit.

It now stages and commits through commitFiles() like the other sites. resetIndex() and commitStagedChanges() have no callers left and are removed; the scoped resetPaths() sibling is untouched.

The reset came from BACK-163, whose acceptance criteria were that task edit "should only stage and commit the modified task file" and "should not commit any other staged or unstaged changes in the repository". Isolation was the goal and the reset was the mechanism; a scoped commit reaches the same goal without clearing an index it does not own.

The new test fails on the old code, where the peer's staged path comes back empty, and passes now. bunx tsc --noEmit clean and suite green.

commitFiles pathspecs the `git diff --name-only --cached` output to scope the
commit. Under git's default core.quotepath, a non-ASCII path (e.g. an em dash
in a task filename) comes back octal-escaped and double-quoted, so the quoted
pathspec matches no file: the commit aborts with "did not match any file(s)"
and the file is left staged.

Force core.quotepath=false at the execGit spawn so every git command emits raw
UTF-8 paths, keeping the scoped-commit pathspec correct. Adds a regression test
that creates a task with an em-dash title under autoCommit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

autoCommit stages the whole backlog dir and commits with no pathspec, sweeping in unrelated dirty state

1 participant