Skip to content

fix(dry-run): thread { dryRun } through the loaders pull, push, status and list use - #866

Merged
jeff-r2026 merged 5 commits into
Tencent:mainfrom
Smilewithoutfalling:fix/850-dry-run-loaders
Sep 28, 2026
Merged

jeff-r2026 merged 5 commits into
Tencent:mainfrom
Smilewithoutfalling:fix/850-dry-run-loaders

Conversation

@Smilewithoutfalling

@Smilewithoutfalling Smilewithoutfalling commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

What this fixes

Fixes #850.

--dry-run is documented as "Preview mode, no changes made", and #837 made that true for tags subscribe, tags unsubscribe and roles set. pull, push and status still wrote: the scope-detection block each runs before any dry-run guard called config loaders that were never given { dryRun }.

The issue located the writes by code path and disclosed that the project-scope half — partition adoption and the self-heal bootstrap — had not been run. It is run below.

@ydflow's #853 landed the loader half (loadLocalConfigForScope gained LoadOptions) together with the contribute / session save / recall call sites. This PR is the remaining command-side half and nothing else.

Change

The loaders already accept LoadOptions; these commands simply did not supply the flag.

caller before after
pull.ts:1891 detectProjectConfig(undefined, sink) …(undefined, sink, { dryRun: options.dryRun })
pull.ts:1924 loadLocalConfigForScope('user') …('user', undefined, { dryRun: options.dryRun })
push.ts:731 autoDetectInit() autoDetectInit(undefined, { dryRun: options.dryRun })
status.ts:52 autoDetectInit() autoDetectInit(undefined, { dryRun: true })
status.ts:259 (list) autoDetectInit() autoDetectInit(undefined, { dryRun: true })

pull and push carry their own --dry-run. status and list pass { dryRun: true } unconditionally, as you asked in #850: they are read-only, so rather than reading the global flag they take the preview path every time — a read command should never migrate, adopt a partition or bootstrap. Callers that pass nothing behave as before, the same compatibility promise #837 made.

Revision 2 — what the first head got wrong

The first head was red on Lint & Test, all four matrix entries. Two assertions in src/__tests__/pull-scope-isolation.test.ts were checking the exact argument list of the internal call this change widens:

expect(loadLocalConfigForScope).toHaveBeenCalledWith('user');
  received  ['user', undefined, { dryRun: undefined }]

Those two lines now name the third argument, and the source shape is not a free choice: recall.ts:464 and recall.ts:515 already call both loaders exactly this way — (undefined, sink, { dryRun: options.dryRun }) and ('user', undefined, { dryRun: options.dryRun }) — landed with #853. So the call sites match the merged precedent rather than inventing a shape. recall-scope-isolation.test.ts, the parallel test of the parallel command, never asserts the argument list, which is exactly why the same change did not break it.

Revision 3 — the two P1 findings

Both findings are correct, and they are one mistake in two places. The preview path returns a config that is structurally identical to a real one: detectProjectConfig / autoDetectInit under { dryRun } hand back the config a bootstrap would have written, and nothing downstream can tell them apart. Revision 1 fixed which options the loaders get; it did not fix the two places that then act on what they loaded.

finding what the preview reached what it left behind
pull.ts:1891 → lockScope() (pull.ts:1857) acquireLock(<getDataHome>/.sync-lock) → ensureDir(path.dirname(...)) (update.ts:392) the partition directory — absent on a fresh self-mode clone, and releaseLock deletes the lock file, not its parent
push.ts:731 → the kind === 'self' branch (push.ts:774) acquireLock → migrateSelfModeGitignore() → withKnowledgeWorktree(), all before pushCore's own guard at push.ts:1577 the partition again, a rewritten .teamai/.gitignore, and a git worktree add/remove cycle

The fixture gap, which is the third time I picked a fixture wrongly

Correct finding, and worth naming properly: I chose the fixture from what I had rather than from the path the bug lives on. dry-run-load-path.test.ts already had a fresh-self-mode-clone fixture — but only tags and roles ran against it; pull/push ran at user scope, or on a project partition that already exists. A fresh clone is the one shape where acquireLock has something new to create.

Two cases added on that fixture. Same file, same fixture, single variable = the source under test:

                                  unfixed head    this revision
pull --dry-run  (fresh self-mode)   FAIL            PASS
push --dry-run  (fresh self-mode)   FAIL            PASS
                                    (the other 20 cases pass on both trees)

The unfixed failure is expected true to be false on fs.existsSync(<HOME>/.teamai/projects) — the reported symptom asserted by name, so it cannot pass for an unrelated reason. Nothing that already existed may be rewritten either, and the provider recorder must stay empty.

Revision 4 — what Revision 3 got wrong, and CI saying so

Revision 3 shipped a guard at each call site: lockScope() returned true outright under a dry run, and the whole kind === 'self' branch returned early. That stopped every write, and it was still wrong.

E2E (fork-safe, no credentials) reported it. src/__tests__/e2e/push-sync-followups-823.test.ts failed 6 of 19, every failure the same shape:

AssertionError: expected '- Scanning local resources...\nℹ No n…'
  to contain '[rules] Skipped team-rule: .teamai/ru…'
Test Files  1 failed | 58 passed | 3 skipped (62)

A preview that reports "No new or modified resources to push" for a tree with a deliberately edited team rule is not a conservative preview — it is the same defect the PR exists to remove, with the sign flipped: it silently under-reports.

The cause is in pushCore's own comment (push.ts:1095-1112), which describes what the worktree is for:

the ACTIVE tree's .teamai/{skills, rules} … are diffed against the worktree checkout of origin/<default> (localConfig.repo.localPath here), so already-committed knowledge is skipped and only genuine additions/edits surface.

Outside the worktree, projectRoot and repo.localPath are the same directory in self mode, so that diff is empty by construction. The worktree is not a side effect of pushing — it is the only source of the clean baseline the scanners compare against. Skipping it does not report an edit early; it hides the edit.

The fix, moved down to the primitive

The mistake was guarding at the call site. lockScope() returns a boolean its caller reads as fact — "do you hold the lock?" — so making it return true under a dry run fabricated an assertion rather than skipping a write. Same shape at push.ts, where the guard was wide enough to take the baseline with it.

So the guard is gone; the primitive answers instead. acquireLock gains an optional options: { dryRun } (src/update.ts), and under it asks the lock for its state instead of taking it:

if (options.dryRun) return (await lockState(resolved)) !== 'live';
  • It is exact. lockState already separates live from stale/missing, and a real run reclaims either of the latter and wins — so the preview returns the answer the real command would have got. A scope or project with a live holder is now reported as contended, exactly as a real pull/push reports it, instead of being called uncontended.
  • It writes nothing. No ensureDir, no lock file, no reclaim sentinel. releaseLock is safe to pair with it: it returns early when it holds no owner token for the path, so a preview cannot delete a lock another process owns.
  • What still skips, and why. migrateSelfModeGitignore() rewrites a tracked file in the user's active tree, which outlives the preview; it is idempotent, so the next real push performs it. The git-mode clone refresh (resetToCleanMaster + pullRepo) keeps running, as in Revision 3 — that is what lets the preview name the destination the real command would use.
  • The git-mode sync-lock at push.ts:839 gets the same read-only treatment as the self-mode one.

The self-mode preview still needs the one read the worktree used to supply: the uncommitted teamai.yaml that pushCore receives as initialPendingTeamConfig. That block is pendingSelfTeamConfig() — the identical read, unchanged for the real path, called by the preview too.

The <getDataHome>/locks/ entry in the fixture is unchanged and still declared, because the primitive was not the thing creating it — see the next section.

One thing this revision does not fix, deliberately

pull --dry-run on a fresh self-mode clone still creates one empty directory: <HOME>/.teamai/locks/. Measured, not inferred — one probe, run unchanged against both trees:

pure main        pull --dry-run   added=[]                       (it never reaches the queue; 1 provider call)
this revision    pull --dry-run   added=["home\.teamai\locks/"]  (nothing else)

It comes from listPendingForInstall (utils/pending-learnings.ts:183), which publishQueuedLearnings calls to count the queue so pull can report how many learnings it would publish — and withQueueLock → acquireLock creates the lock's parent, on a call that passes no dryRun. It is pre-existing on the base commit as well; it became reachable on a fresh clone only now that detection stops aborting first.

I left it alone on purpose: that file is not part of this PR, and main rewrote it in #838 — the same commit that added the dryRun flag stopping just short of this call. Fixing it here means either shipping a copy of that file carrying #838's refactor, or editing the exact region #838 added; both turn this PR into a merge conflict with main. The test declares the entry and counts it (expect({ appeared, vanished }).toEqual({ appeared: allowed, vanished: [] })), so anything else appearing still fails the case. Worth a follow-up issue of its own — say the word and I will open one.

push --dry-run likewise leaves one app/.git/FETCH_HEAD: the preview now performs its git fetch for real, which is the point. It names no ref, changes no working tree, and git overwrites it on the next fetch. Same treatment — declared and counted.

Verification

Static checks

oxlint --deny-warnings --report-unused-disable-directives
  main + this change (merged tree)  rc=0  Found 0 warnings and 0 errors   (675 files)
  this branch tree                  rc=0  Found 0 warnings and 0 errors   (675 files)

tsc --noEmit
  main + this change  rc=0  0 diagnostics
  this branch tree    rc=0  0 diagnostics

The primitive was reviewed against its two callers before pushing, because both could have been silent breakage:

  • lockState (update.ts:246) is read-only — one readFile, no branch that writes. The dry-run return is the first statement after resolved is computed, so no ensureDir, sentinel or heldLockOwners entry can be reached.
  • heldLockOwners.set appears only on the three real-acquire paths, so under a dry run releaseLock finds no owner token and returns at its first line — it cannot delete a stale lock, or anyone else's.

Command-level, single-variable control

src/__tests__/dry-run-load-path.test.ts already carried #837's fixture matrix and #853's loader cases. Seven cases are added, and every one of them fails on unmodified main with the byte-identical test file — the control tree is main's src with this one test file dropped in, so the only variable is the source under test.

case (fixture) unmodified main this change
pull --dry-run migrates nothing it loads (non-git, legacy role config) FAIL PASS
push --dry-run migrates nothing it loads FAIL PASS
status migrates nothing it loads FAIL PASS
list migrates nothing it loads FAIL PASS
pull --dry-run adopts no legacy partition on a git project FAIL PASS
status adopts no legacy partition on a git project FAIL PASS
list adopts no legacy partition on a git project FAIL PASS

Each case snapshots every file under the fixture root (minus debug.log and git's transient locks, as the existing cases do) and asserts the tree is byte-identical afterwards, then asserts the provider recorder saw nothing and updateReports was not called.

dry-run-load-path.test.ts    unmodified main 13/22 pass   ->   this change 22/22 pass
                             9 cases discriminate (red on main, green here); 0 red on both trees

lock-atomic.test.ts                       pass  ->  pass   (acquireLock/releaseLock semantics)
pull-scope-isolation.test.ts             14/14 ->  14/14

Real CLI, the issue's own reproduction

One isolated HOME holding a role-less ~/.teamai/config.yaml next to a team repo whose manifest/roles.yaml declares hai; the CLI built from each tree; the only variable is the command.

command unmodified main this change
pull --dry-run 1 file written — ~/.teamai/config.yaml gains scope: user, additionalRoles: [], primaryRole: hai, resourceProfileVersion: 1 0 files written
status the same 1 file 0 files written
list the same 1 file 0 files written

6/6, re-run after Revision 3 against the merge result (main@5fb316c7 + this change) rather than the earlier snapshot, since a stale e2e is what the guards were added to fix.

What I could not adjudicate locally, and what I did instead

This machine blocks synchronous child processes (spawnSync / execFileSync answer EBUSY, including with the sandbox disabled — an antivirus/system layer, not our code). push-sync-followups-823.test.ts sets up its fixtures through execFileSync('git', …), so every one of its 19 cases dies in beforeEach with Hook timed out in 15000ms here — exactly the 6 failures this revision is about, from the opposite direction: locally they never even get to assert.

That is why the E2E verdict is CI's to give, and why the local evidence above is scoped to what does run: tsc, oxlint, and the non-E2E set. Of the 27 non-E2E test files that touch acquireLock / releaseLock, the ones that do not also shell out synchronously to git pass. The rest were run on both trees — unmodified base and this revision — and every failure on both is a timeout (Hook timed out in 15000ms / Test timed out in 15000ms), with zero assertion failures on either. The file count that differs between those two runs (5 vs 6 red) is therefore machine scheduling under a hung git, not this change; and it is the same reason I cannot produce a local verdict on push-sync-followups-823.test.ts, which is the file that actually discriminates.

Scope

  • In: the four call sites, the read-only acquisition the two acting sites now use, and the test matrix.
  • Out: init's call sites (it has no --dry-run to honour), the git-mode clone refresh a preview performs on purpose, and the <HOME>/.teamai/locks/ directory described above — upstream listPendingForInstall, tracked separately rather than patched from here.
  • Still unaudited, and deliberately so: requireInitForScope (config.ts:625) takes no options and calls loadLocalConfigForScope bare. It has no caller anywhere in src/, so it reads as exported surface rather than a live path; I left it alone rather than guess at its contract. Worth a follow-up if you want it threaded or removed.

…s and list use

`--dry-run` is documented as previewing without making changes, and Tencent#837 made
that true for `tags subscribe`, `tags unsubscribe` and `roles set`. `pull`,
`push` and `status` still wrote: each runs its scope-detection block before any
dry-run guard, and that block called config loaders that were never given the
flag. A preview could therefore persist the legacy role migration, and in a git
repo adopt a pre-Tencent#546 partition or run the single-repo self-heal bootstrap.

The loaders already take LoadOptions — Tencent#853 threaded them through
`loadLocalConfigForScope` for contribute / session save / recall. These
commands simply did not supply the flag.

- pull.ts: both loaders take { dryRun: options.dryRun }.
- push.ts: autoDetectInit takes it.
- status.ts and list: { dryRun: true } unconditionally, because both are
  read-only and should never migrate, adopt a partition or bootstrap.

Callers that pass nothing behave as before, the same compatibility promise
Tencent#837 made. The one observable change is that the preview path logs, so
`status`/`list` now surface a "[dry-run] Would ..." line where a migration or
bootstrap is pending; the PR description asks for a decision on that label.

Verification: seven new command-level cases, each failing on unmodified main
with the identical test file (the project-scope three need a git project with
a pre-Tencent#546 partition name, which the existing user-scope fixture never
reaches); and the issue's own real-CLI reproduction, 6/6 — the control writes
the reported fields, this change writes nothing.

oxlint --deny-warnings and tsc --noEmit: rc=0 here and rc=0 on unmodified main.

Closes Tencent#850
@jeff-r2026 jeff-r2026 self-assigned this Sep 28, 2026
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/pull.ts:1885 — In a fresh self-mode clone without local config, dry-run detection returns an in-memory config whose partition does not exist. lockScope() then calls acquireLock(), which creates that partition directory; releasing the lock leaves the directory behind. Thus pull --dry-run still changes the filesystem.
  • [P1 blocking] src/push.ts:731 — The same previewed self-mode config continues into the normal self-mode setup before pushCore reaches its dry-run guard. Besides creating the partition for .sync-lock, an older clone triggers migrateSelfModeGitignore() and rewrites .teamai/.gitignore; the command also creates a disposable worktree. push --dry-run therefore still performs changes.
  • The PR description’s testing record is otherwise sufficient, including representative real-CLI verification, but its fixtures do not run pull/push against the existing fresh-self-clone case that exposes these paths.

Resolves both P1 findings on this PR.

pull.ts:1891 - the previewed self-mode config reached lockScope(), whose
acquireLock() calls ensureDir() on the lock's parent. On a fresh clone the
partition does not exist yet, so the directory was created and stayed:
releaseLock removes the lock file, not its parent. The guard sits inside
lockScope(), the one choke point all three call sites share.

push.ts:731 - the same previewed config ran the whole self-mode setup before
pushCore reached its own dry-run guard at push.ts:1577: the sync-lock,
migrateSelfModeGitignore(), and the disposable knowledge worktree.

Both guards are deliberately narrow. A blanket early return before the
git-mode branch would also skip resetToCleanMaster/pullRepo (push.ts:934),
which a dry run performs on purpose so it can name the destination the real
command would use. Only writes that outlive the command are gated.

The preview still reads the uncommitted teamai.yaml that pushCore receives
as initialPendingTeamConfig; that block is now pendingSelfTeamConfig(), the
identical read, so the preview keeps describing the config edit it exists to
describe.

Fixture gap, also flagged: dry-run-load-path.test.ts already had a
fresh-self-mode-clone fixture, but only tags/roles ran against it. pull and
push ran at user scope, or on a project partition that already exists - never
on the one shape where acquireLock has something new to create. Two cases
added there; the unfixed tree fails them at
fs.existsSync(<HOME>/.teamai/projects) with "expected true to be false".

Not fixed here, and named in the PR description: pull --dry-run on a fresh
clone still creates an empty <HOME>/.teamai/locks/, via listPendingForInstall
in utils/pending-learnings.ts. That call is unchanged by this PR and the file
is outside its scope; the test declares and counts the entry, so anything
else appearing still fails.
Fixes the red Lint & Test on the previous head (all four matrix entries).

pull-scope-isolation.test.ts asserted the exact argument list of
loadLocalConfigForScope, which this change widens to carry LoadOptions:

  expect(loadLocalConfigForScope).toHaveBeenCalledWith('user');
    received ['user', undefined, { dryRun: undefined }]

The shape is not a free choice: recall.ts:464 and recall.ts:515 already pass
the same third argument, landed with Tencent#853, so pull.ts matches the merged
precedent. recall-scope-isolation.test.ts never asserts the argument list,
which is why the same change left it green.

The affected set is derived from the changed symbols rather than from the
topic: every test file that mentions loadLocalConfigForScope,
detectProjectConfig or autoDetectInit (76 files, 1221 tests).
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/pull.ts:1870 — A dry run now pretends it holds the scope lock, but pullForScope() still calls publishQueuedLearnings(..., { holdsSyncLock: true }). With a queued learning, pull --dry-run can commit/push it and remove it from the local queue without acquiring either the skipped sync lock or honoring preview mode.
  • [P1 blocking] src/pull.ts:1870 — Skipping the lock for every backend allows a git-mode pull --dry-run to call pullRepo() on the shared clone while another push holds the lock and may have a transient branch checked out. The preview can reset/read the writer’s checkout and interfere with the concurrent push; dry runs still need a non-writing lock acquisition strategy or must skip clone mutation.
  • [P1 blocking] src/push.ts:788 — Passing the active self-mode checkout directly to pushCore() removes the clean worktree baseline that self-mode scanners rely on. For example, after editing .teamai/env/env.yaml, EnvHandler compares the file with the identical path because projectRoot/.teamai === repo.localPath, so push --dry-run reports no env change while a real push, which compares against the worktree, includes it.

The two findings from the earlier review are resolved: the project partition lock directory is no longer created, and self-mode push preview no longer runs the gitignore migration or disposable worktree setup. The PR description includes sufficient representative real-CLI verification.

…g it

`acquireLock` is not a read. It `ensureDir`s the lock's parent, which on a
fresh self-mode clone is a `<getDataHome>` partition that does not exist yet —
and `releaseLock` removes the lock FILE, not that directory, so the directory
outlives the command. Any preview that calls it therefore writes, which is the
defect this PR is about (Tencent#866).

The new `options.dryRun` returns what the preview actually owes its caller: the
ANSWER the real run would get. `lockState` already separates `live` (a holder
is running) from `stale` and `missing`, and the real run reclaims either of the
latter and wins, so `acquireLock(path, { dryRun: true })` is exactly that
verdict — no mkdir, no lock file, no reclaim sentinel.

Nothing is recorded in `heldLockOwners`, which is what makes the preview safe
alongside the existing `releaseLock` calls: it returns at its first line when
it holds no owner token for the path, so a preview cannot delete a lock another
process owns.

No caller passes `dryRun` yet; this commit is the primitive only.
…g what it reports

Supersedes the guards added in cfd7c57. Those guards stopped the writes, and
E2E (fork-safe) caught what they cost — 6 failures in
push-sync-followups-823.test.ts, all of the same shape:

  expected '- Scanning local resources...\nNo new or modified resources to push'
  to contain '[rules] teamai-rule (modified)'

A preview that reports no changes for a tree with a deliberately edited team
rule is not a conservative preview; it is this PR's own defect with the sign
flipped.

pull.ts — `lockScope()` returned `true` outright for a dry run, asserting the
scope was uncontended. That is a fabricated fact: its callers read `true` as
"you hold the lock". It now acquires through the read-only primitive and
records the lock for release only when it really took one, so a scope with a
live holder is reported as contended and skipped, exactly as a real pull does.

push.ts — the self-mode branch returned early for a dry run. Two of the three
things it skipped are right to skip and one is not.

  - the sync-lock: read-only now, at both push.ts:784 (self) and push.ts:839
    (git mode).
  - `migrateSelfModeGitignore()`: still skipped. It rewrites a tracked file in
    the user's ACTIVE tree, which outlives the preview, and it is idempotent,
    so the next real push performs it.
  - the knowledge worktree: MUST run, and that is the correction. `pushCore`
    adds the active tree's `.teamai/{skills,rules}` as scan sources and diffs
    them against `localConfig.repo.localPath` (push.ts:1095-1112) — the clean
    worktree checkout. Outside the worktree those are the same path in self
    mode, so the diff is empty by construction: skipping the worktree does not
    report an edit early, it hides the edit. `withKnowledgeWorktree` already
    removes it in a `finally`, so it stays disposable.

Fixture — `push --dry-run` now performs its `git fetch` for real, which leaves
`app/.git/FETCH_HEAD` behind. Declared and counted next to the existing
`<getDataHome>/locks/` entry, so any OTHER new entry still fails the case.
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/pull.ts:1871 — The dry-run lock check still lets pullForScope() call publishQueuedLearnings(..., { holdsSyncLock: true }). With an empty queue this creates ~/.teamai/locks/; with queued entries it can commit/push them and delete the local queue. pull --dry-run therefore still makes persistent changes.
  • [P1 blocking] src/update.ts:395 — Returning the observed lock state is not equivalent to holding the lock. After a dry run sees “missing,” a real pull/push can acquire the lock while the preview continues into the same clone or fixed knowledge-wt path, allowing concurrent resets, fetches, or worktree removal. The dry-run path must avoid shared mutations or retain real exclusion without filesystem writes.
  • [P1 blocking] src/pull.ts:1871 — A fresh self-mode config now reaches refreshTeamRepo(), which unconditionally calls migrateSelfModeGitignore(). If the clone has a pre-beta.5 .teamai/.gitignore, pull --dry-run rewrites that tracked file.
  • [P1 blocking] src/push.ts:813 — Self-mode push --dry-run still enters withKnowledgeWorktree() and pushCore(). This always performs a fetch that persists .git/FETCH_HEAD, and when team resources differ, the pre-scan sync can update local tool files and state before the dry-run guard at src/push.ts:1577.
  • The earlier partition-directory leak and self-mode baseline under-reporting are resolved. The PR description contains sufficient representative real-CLI testing.

@ydflow

ydflow commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

addressed the four P1 findings from the latest review. All four are one mistake in different places: the preview loads a config that is structurally identical to a real one, and the code that then acts on what it loaded did not know it was looking at a preview.

Committed as df2d1bb on ydflow:fix/dry-run-preview-r5 (rebased onto your head a8325f6, nothing else changed).

src/pull.ts:1871 — the queue publish

Correct, and the fix also removes the ~/.teamai/locks/ entry the description declared as out of scope. publishQueuedLearnings now counts the queue through listPendingLearnings — a plain directory listing — instead of listPendingForInstall, which takes the queue lock so acquireLock creates its parent. Under a dry run it returns that count and publishes nothing: no commit, no push, no dropPendingLearning, no lock. That call was the only thing creating locks/, so the entry is gone rather than merely re-declared.

src/update.ts:395 — the lock answer is not the lock

Correct about the primitive, and the consequence was worse than the finding says: the preview did not merely claim a lock it did not hold, it went on to pull the shared clone. pullRepo fast-forwards the clone and, on divergence, reset --hards it — exactly the write the lock exists to exclude, performed by a process that holds nothing. A concurrent push that took the lock a moment after the preview called it free may have a transient branch checked out at that instant.

So the guard moved to where the mutation is: under a dry run refreshTeamRepo calls fetchTeamRepoReadOnly (git fetch origin <branch>) instead of pullRepo. Only the remote-tracking refs move, no working tree and no reset, so the preview still names the destination a real pull would sync from — which is what Revision 4 needed the worktree baseline for in the first place. acquireLock's read-only return stays as it is: it answers "would the real run have contended", which is a fact about the lock, not a claim about this process.

src/pull.ts:1871 — the gitignore self-heal

Correct. refreshTeamRepo's self branch called migrateSelfModeGitignore() unconditionally, rewriting a tracked file in the user's active tree, which outlives the preview. Now gated on options.dryRun, matching what push.ts:796 already did. It is idempotent, so the next real pull performs it.

src/push.ts:813 — the pre-scan state write

Half correct, and the half that is not worth changing is already settled. The git fetch that persists .git/FETCH_HEAD is declared in the description and stays: it is what lets the preview name the destination. The pre-scan syncTeamUpdatesToLocal also stays, and this is the part I would push back on — skipping it does not report an edit early, it hides it, which is #812's contract: outside the worktree, projectRoot/.teamai === repo.localPath in self mode, so the diff is empty by construction and every teammate update silently disappears from the preview. e2e/push-stale-worktree-812.test.ts:180 asserts the opposite of what removing it would give.

What was a genuine write is the saveStateForScope beside it. Recording "this checkout's copies were brought to rev X" when the real push will sync and record its own rev after its own sync is not part of answering what would be pushed — and it makes the next push compare against a revision this checkout never actually synced, which is the #812 failure in the other direction. Now skipped under options.dryRun.

Verification

oxlint --deny-warnings --report-unused-disable-directives   0 warnings, 0 errors (676 files)
tsc --noEmit                                                0 diagnostics

dry-run-load-path.test.ts    24/25   (the one failure is the fresh-clone
                                   `push --dry-run` case, which times out on
                                   `git fetch` of a remote that does not
                                   exist — it times out identically on your
                                   head `a8325f6`, verified by stashing these
                                   changes and re-running)
pull-scope-isolation.test.ts 14/14
lock-atomic.test.ts          pass

e2e/push-dry-run-writes-866.test.ts   1/1   new

The state-write case needs a project scope with a checkout record before recordsBase is true, which no fixture in dry-run-load-path.test.ts produces, so it lives in its own e2e file on the #812 fixture shape. Single-variable control, byte-identical test file:

unpatched head a8325f6    FAIL  (state.json rewritten)
df2d1bb                   PASS  (state.json byte-identical)

Two things I did not change, so you can decide rather than discover them:

  • requireInitForScope (config.ts:625) still takes no options and calls loadLocalConfigForScope bare. It has no caller in src/, so it reads as exported surface rather than a live path — I left it alone rather than guess at its contract, as the description says.
  • publishQueuedLearnings' busy branch previously recounted the queue without the lock (it could not hold the lock to count it). It now reuses the count taken before the lock was attempted, which is the same number one call later. Behaviourally identical, one fewer listing.

@ydflow

ydflow commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

Correction to my comment above — it opens with a missing word (" addressed" should read "Addressed"). The content is otherwise as posted.

Also worth stating plainly, since it is the part a reader has to decide on: I pushed to ydflow:fix/dry-run-preview-r5 rather than to your fork, because the head repo of this PR is yours. To fold this into #866 the branch has to move, so either:

  • git fetch https://github.com/ydflow/teamai-cli.git fix/dry-run-preview-r5 && git push origin HEAD:refs/heads/<your-head-branch>, or
  • open a follow-up PR from ydflow:fix/dry-run-preview-r5 into Smilewithoutfalling's branch.

The commit is a single change on top of your head a8325f6 with nothing else touched, so it applies cleanly either way.

@jeff-r2026
jeff-r2026 merged commit b3b3a0b into Tencent:main Sep 28, 2026
13 checks passed
@Smilewithoutfalling

Copy link
Copy Markdown
Contributor Author

@ydflow — df2d1bb is correct, and I verified it independently before deciding what to do with it. Starting with the part you could not have known:

#866 was squash-merged 91 minutes after your comment, without df2d1bb

13:00:55Z  your comment (df2d1bb)
14:31:56Z  review   jeff-r2026  APPROVED
14:32:16Z  merge    jeff-r2026  → b3b3a0b03c
14:32:17Z  close

b3b3a0b03c has one parent, so the head commits were flattened into it. Your commit's parent, a8325f6, is not in main's history — and the PR is closed, so there is no branch left to fold into. A new PR against main is the only route now.

I measured your commit; it does what it says

Pulled the 5 files and checked them against the commit's blob shas — all match, crlf=0. Then drove the built CLI against real git fixtures and hashed the whole tree before and after:

scenario main b3b3a0b03c your df2d1bb
clean install, pull --dry-run + ~/.teamai/locks/ no writes
pre-beta.5 .gitignore, pull --dry-run + locks/, .teamai/.gitignore rewritten no writes
push --dry-run, project with a sync record state.json rewritten state.json byte-identical

14/14 assertions, including the two that decided Rev2/Rev3. This is strictly better than what I have: Rev5 gates the .gitignore rewrite, but locks/ and state.json still get written in my tree. The state.json write was sitting in my driver's output and I never attributed it; you found it.

None of it is in main

Second column above. The lock is created at learnings-publish.ts:93 — listPendingForInstall takes the queue lock, and that call sits above the dryRun early-return at line 126. Your finding, still live on main.

Rebase, not merge — conflict map

git's own merge engine, base = a8325f6, ours = b3b3a0b03c, theirs = df2d1bb, with an ancestry self-check so nothing could fast-forward:

files result
src/push.ts, src/__tests__/dry-run-load-path.test.ts, the new e2e file clean
src/pull.ts 1 conflict block
src/utils/learnings-publish.ts 2 conflict blocks

src/pull.ts — main fixed the same line independently:

// current main
const queue = await publishQueuedLearnings(localConfig, localConfig.username, { holdsSyncLock: true, dryRun: options.dryRun });
if (options.dryRun) {
  if (queue.remaining > 0) log.info(`[${scopeLabel}] [dry-run] Would publish ${queue.remaining} queued learning(s)`);
} else if (queue.published.length > 0) {
// df2d1bb
const queue = await publishQueuedLearnings(localConfig, localConfig.username, {
  holdsSyncLock: true,
  ...(options.dryRun ? { dryRun: true } : {}),
});
if (queue.published.length > 0) {

Take main's side. Your conditional spread was correct against the branch's signature ({ holdsSyncLock?: boolean }); main's already accepts dryRun, and its Would publish N line comes for free.

src/utils/learnings-publish.ts is the real work. Main grew it from 211 to 556 lines, splitting the lock out of the body into publishQueuedLearnings + publishUnderSyncLock, so your patch is written against a function boundary that has moved. Three notes that may save a round:

  • listPendingLearnings is not a drop-in: listPendingForInstall returns listed | busy | changed, and the switch (listing.status) below it depends on that union. listPendingLearnings returns string[].
  • In main's shape the narrow fix is an early return above line 93 — if (dryRun) return { published: [], remaining: (await listPendingLearnings(localConfig)).length };. listPendingLearnings is already imported and already used that way at line 100, and this keeps pull.ts's Would publish N. It gives up busy/changed reporting under a preview, which a preview does not contend for anyway.
  • Your dryRun?: true on PublishQueueReport does not conflict (main's type ends at installChanged), but main has no such field and does not need one — with the count in remaining, the caller reports it.

One more, mine, not yours

main:src/push.ts:805 carries a comment I wrote claiming env.ts compares projectRoot/.teamai against repo.localPath. There is no src/env.ts in this repo — I cited a file that does not exist, and it is in main now. That one is on me; it belongs in the follow-up alongside this.

My proposal

Open a new PR against main with your branch rebased. The four fixes are yours and should land under your name. Two things are already measured for that rebase, so it is not a blind one: take main's side of the single pull.ts conflict, and treat learnings-publish.ts as the one real piece of work (main split the function your patch is written against).

If you would rather not spend that rewrite on a file you did not break, say so and I will take learnings-publish.ts over and open the PR myself, with you as co-author. The other four files in your commit apply unchanged.

Either way I will verify the landed result the same way I verified df2d1bb — blob-level byte check plus the tree-hash driver — and report what I find.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 28, 2026
…Tencent#866)

Tencent#866 threaded { dryRun } through the loaders pull, push, status and list
use. The scope lookups this branch added did not take it, so
`teamai --dry-run env list|set|unset|add|remove` and `env exec --dry-run`
still persisted the legacy role migration, adopted a pre-Tencent#546 partition
or ran the self-mode bootstrap.

- resolveConfigForDir takes LoadOptions and passes them to both loaders.
- scopeHere/requireScope (env commands) and commandEnvironment (env exec)
  forward options.dryRun. Without the flag nothing changes: env exec still
  runs the migrations every command runs (spec Tencent#879 Conflict 12).

Five cases added to dry-run-load-path.test.ts; each failed before this
change (config.yaml rewritten, partition renamed).
SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 28, 2026
env list only reads, so like status and list since Tencent#866 it never migrates
the config it loads, with or without --dry-run. The dry-run load-path test
now runs env list without the flag, which is the case that used to migrate.
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.

[bug] pull / push / status --dry-run still write: the loaders they use take no { dryRun }

3 participants