Skip to content

perf: 27% faster CLI, 21-29% faster hooks, 14% faster pull - #1226

Merged
jeff-r2026 merged 15 commits into
Tencent:mainfrom
SaulMoro:perf/cli-startup-and-pull
Oct 20, 2026
Merged

jeff-r2026 merged 15 commits into
Tencent:mainfrom
SaulMoro:perf/cli-startup-and-pull

Conversation

@SaulMoro

@SaulMoro SaulMoro commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Production code only: what the CLI does on every start, and on pull. Dashboard and test speed are out of scope.

 teamai <command>                         (every command, each AI-tool hook included)
   load dist/cli.js
-    decode the 5.5 MB bundle as UTF-16    (a few em dashes made it two bytes per char)
+    pure-ASCII bundle, one byte per char  (tsup plugin, fails the build on anything left)
-    import ora → string-width setup       (~6 ms, even where nothing spins)
+    import ora on the first spinner
   resolve project anchors
-    git rev-parse + git worktree list
+    answers kept under the same stamps in <tmpdir>/teamai-<uid>/git-answers-<version>.json
 exit
-  wait up to 50 ms for simple-git's exit-fallback timer
+  timer unref'd (build-time patch, fails the build when it matches nothing)

 teamai pull
-  workflows: rev-parse HEAD, for-each-ref --contains, rev-parse --show-toplevel   (even with no workflows)
+  asked on the first valid workflow file
-  reports/learnings: status → rev-list --count → merge --ff-only
+  status ∥ rev-list --left-right --count; stop when already at origin
-  "Skipping skill sync for <tool>: tool not installed" once per skill per tool (481 lines → sync appends)
+  debugOnce: 123 lines, whatever the number of skills

The kept git answers live in src/utils/git-answers.ts, one module that owns both layers (this run's memory, then the file). The file is trusted only when its directory is this user's alone (uid, mode 0700, not a symlink). Where that can't be checked (Windows), nothing is kept. The repository stamp now also covers config, config.worktree, the common config, commondir, the real path each core.worktree there resolves to (git follows its symlinks) and the variables that stop git's search. The worktree-list stamp covers the common config, each checkout's own config.worktree and the GIT_CONFIG* variables, everything git reads the main entry's core.bare from. The file name carries the CLI version, so another release never reads these answers.

Evidence

Before: origin/main 8dc60b0 · After: 72e837c (the timed code; later commits change review-found edge cases, not the hot path). Interleaved hyperfine A/B, 3 rounds × 10 runs per side per command (order alternated per round, 3 warmups each). Node 24.21, Apple silicon. Fixture: a git-provider team with 36 skills, 6 rules, 3 agents, 20 learnings and Claude hooks injected. Medians:

command weight before after change
hook post-tool-use 4 91.2 ms 70.2 ms -23.0%
hook prompt-submit 2 104.8 ms 74.5 ms -28.9%
hook session-start 1 104.0 ms 79.1 ms -24.0%
hook stop 1 101.3 ms 80.3 ms -20.8%
pull 4 374.8 ms 321.0 ms -14.4%
skill get 1 67.4 ms 55.1 ms -18.3%
recall 2 129.5 ms 75.0 ms -42.1%
env exec 1 133.1 ms 67.9 ms -49.0%
status 1 172.2 ms 125.5 ms -27.1%
list 1 136.2 ms 82.3 ms -39.5%
push --dry-run 1 239.0 ms 168.1 ms -29.7%
doctor – 188.5 ms 160.6 ms -14.8%
--version – 59.7 ms 49.2 ms -17.6%
weighted geomean -27.1%
  • How to read it: the weights are how often a user waits on each command (hooks and pull most). The means give the same -27.1%. Each command's output and exit code are identical between before and after.
  • Debug log at 4x content: debug.log lines per pull went from 1,599 to 123. Git processes per pull stay the same at 1x and 4x content (16).
  • Real-CLI record: the table above, run with the built dist/index.js against the fixture.
  • Cold start: with the kept file deleted before every run, hook post-tool-use took ~80 ms on head against ~97 ms on base, so a first run after install or temp cleanup is not slower.
  • Gates on 1f81044: npx tsc --noEmit and npm run lint pass. npx vitest run: 471 files, 11,223 passed, 21 skipped. npm run test:e2e full on 72e837c (test isolation changed): 157 files, 1,312 passed, 27 skipped; CI runs it on the head.
  • Real-CLI re-check on 800f06b: pull, status, list, hook post-tool-use, recall, push --dry-run exit 0 with output equal to base apart from timings and random ids; git-answers-<version>.json is written in a 0700 directory; a linked worktree's kept list agrees with git worktree list --porcelain after the main checkout is made bare in its config.worktree. On 1f81044 (the last production change), the built CLI's hook post-tool-use spawns git 6 times cold and 4 warm on the fixture, as before the change. The syncWorktree reorder (restore before a failed count can throw) is verified by reading only.
  • Kept-answer gaps from review: each was first a failing test against 6cfef99, then passed with the fix:
    core.worktree set in the per-worktree config      ✗ → ✓
    keeps every answer an earlier run asked for at once ✗ → ✓
    answers nothing for a directory removed since       ✗ → ✓
    keeps no answers where it cannot tell the owner     ✗ → ✓
    lists no main checkout made bare in its own config  ✗ → ✓
    lists a worktree added after an earlier run asked   ✓ (guard)
    asks git again when the work tree moved between runs ✓ (fails if the stamp is ignored)
    core.worktree changed in a file the config includes  ✓ (git ignores it; pins the CI review's P1)
    core.worktree symlink retargeted (to a subdirectory, to its own checkout,
      before a `..`, after another section header, beside a `.git` symlink) ✗ → ✓
    a submodule kept between runs                       ✓ (guard: core.worktree is always set there)
    main checkout made bare through an included file, an include header after
      another on its line, or GIT_CONFIG_* variables     ✗ → ✓ (the include case was already right)
    

Measured and dropped (within noise or slower):

  • esbuild splitting (pull +17%)
  • whitespace and identifier minification
  • --no-lazy compile
  • lazy externals
  • keeping debug.log open
  • lazy esprima
  • dropping simple-git's quiet-command 50 ms wait

Not kept, asked of git each run instead: a repository whose own config has an [include] or [includeIf] (git may read core.bare from the included file; it ignores an included core.worktree). A worktree config line the stamp cannot read (quoted, escaped, commented) leaves no stamp, so that repository asks git every run.

Not covered: gitlab/github providers and Codex/CodeBuddy/OpenCode agents beyond what the e2e suite runs. Left to CI.

Merge Danger

Door: two-way

Nothing persisted moves. The only new file is a disposable cache in the temporary directory. Deleting it or reverting this PR costs one extra git rev-parse and git worktree list per command.

Blast Radius: startup

Every command and hook goes through the kept git answers. A stale answer would point teamai at the wrong checkout root or exclude file. The stamps cover what git reads to find the repository, and the agreement tests compare teamai with git for each change case. The two build plugins fail closed: a simple-git release that changes the patched line or moves its entry, or a non-ASCII character outside a comment or regex, fails npm run build rather than shipping slower.

simple-git settles a git command on close, or 50 ms after exit when close
never comes. The 50 ms timer stayed armed after close won, so every command
that ends on its own waited up to 50 ms after its last git call. The build
now unrefs that timer, and fails if simple-git changes the code it patches.
Importing ora up front ran string-width's setup (an Intl.Segmenter and a
Unicode-property regex) in every command, hooks included. spinner() now
imports ora on first use, so its callers await it.
Every command, each hook included, started git twice (rev-parse and
worktree list) to resolve project anchors that only change with the
repository stamp. Keep those answers under the same stamps in
teamai-<uid>/git-answers.json in the temporary directory, beside Node's
compile cache, so a dry run still leaves the TeamAI home untouched. The
directory is trusted only when it is this user's alone. The repository
stamp now also covers git's config files, the commondir link and the
search-limit variables, so a core.worktree change is seen within a run
and across runs.
The store moves to src/utils/git-answers.ts, which owns both layers
(this run's memory, then the file) so each call site asks once. Writes
from one run go one at a time, each taking every answer not yet written:
answers asked for at once were lost before. A worktree list is kept per
repository (its common directory), not per directory. A directory that
no longer exists gets no stamp, so it is never answered from an earlier
run, and a worktrees/ directory that cannot be read for a reason other
than being absent keeps nothing. Windows, where the directory owner
cannot be checked this way, keeps nothing, and config.worktree is part
of the repository stamp.

Tests now get their own temporary directory, so the suite neither shares
answers nor fills the file the real CLI reads. The dry-run contract
records that temporary-directory caches are not something a dry run
changes.
The patch threw only when a matched file lacked the patched text. A simple-git release that moves its entry, or a require() reaching the CJS build, matched nothing and shipped the 50 ms wait silently. Both builds now match, and the build fails when nothing was patched.
V8 keeps a source holding any non-ASCII character as two bytes per
character, so a few em dashes made every run decode the 5.5 MB bundle
into 11 MB of UTF-16, then hash, compile and collect it. esbuild's ASCII
charset escapes strings, templates and identifiers, but not regular
expressions or the comments it keeps. A tsup plugin escapes those with
acorn's tokenizer, and fails the build on any non-ASCII character left
elsewhere or following a backslash.
Skill, rule and agent sync asked each tool whether it is installed for
every item, and wrote "Skipping ... sync for <tool>: tool not installed"
each time: one synchronous append per skill for every tool not
installed, 481 lines in a pull of 36 skills and 1,599 at four times the
content. debugOnce, beside warnOnce, writes such a line once per run; a
pull now writes 123 lines whatever the number of skills.
…eds it

Workflow sync ran rev-parse HEAD, for-each-ref --contains and rev-parse --show-toplevel on every pull before listing workflows/. The revision is now resolved on the first valid workflow file, so a team without workflows starts no git there.
…at origin

Each refresh ran status, rev-list --count and merge --ff-only one after another. The status and one rev-list --left-right --count (behind and ahead) now run together, and a worktree already at origin's commit stops there.
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] Included Git configuration does not invalidate cached checkout locations — src/utils/git-discovery.ts:80. The stamp tracks configuration files themselves, but not files they load through include.path or includeIf. For example, cache a checkout with core.worktree supplied by an external included file, then change that file’s worktree from the repository root to an existing subdirectory. Running from that subdirectory leaves every stamped file unchanged, so subsequent CLI runs return the old toplevel and prefix instead of Git’s current answer, directing project resolution at the wrong root. Track these configuration dependencies or disable caching when they cannot be stamped.

The PR description documents sufficient testing: a built real-CLI run, output/exit-code comparisons, and unit/e2e results at the head commit. No testing-record finding. Nothing was run, built, or installed.

Git takes core.worktree from the repository's own config files and never from a file they include (include.path, includeIf), so the repository stamp leaves included files out. An agreement case edits only the included file after the first answer and expects git's answer, so it fails first should git ever read core.worktree there.
… per version

- linkedWorktreesStamp also stats config.worktree, where a main checkout can
  be made bare; a later run then lists no main checkout, as git does.
- The kept-answers file name carries the CLI version.
- syncWorktree finishes restoring conflicted files before a failed count throws.
- The skip debug lines name the scope, so debugOnce keeps one per scope.
@SaulMoro
SaulMoro force-pushed the perf/cli-startup-and-pull branch from 72e837c to 7e35228 Compare October 19, 2026 23:38
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Re the [P1 blocking] on included config and cached checkout locations: not reproducible. Git takes core.worktree only from the repository's own config (and config.worktree) during discovery, never from a file those include, so the cached toplevel/prefix cannot go stale this way.

Probe on git 2.56.0:

git config include.path ../included.cfg      # included.cfg: [core] worktree = <repo>/sub
git config --get core.worktree               -> <repo>/sub      (config sees it)
git -C sub rev-parse --show-toplevel         -> <repo>          (discovery ignores it)
git config core.worktree <repo>/sub          # set directly
git -C sub rev-parse --show-toplevel         -> <repo>/sub      (stamped: config changes)

Same result with includeIf "gitdir:...". Pinned by the agreement case core.worktree changed in a file the config includes in src/__tests__/git-discovery.test.ts (b36a136), which compares teamai's answer with git's before and after rewriting the included file. CI runs it against the runners' git versions. A direct core.worktree change, including in config.worktree, changes the stamp and is covered by its own case.

The real gap nearby is core.bare in an included file, which git worktree list does honour. It is now stated in linkedWorktreesStamp's doc as unstamped (it needs core.bare set through an include and changed between runs).

@github-actions

Copy link
Copy Markdown
  • [P3 nit] Retargeting a core.worktree symlink leaves the persisted location stale — src/utils/git-discovery.ts:85. If core.worktree names an external symlink, cache the location from the directory containing .git, then retarget that symlink to another existing directory. None of the stamped paths changes, so later runs retain the old toplevel and resolve the wrong project root. Stamp the resolved worktree target or bypass persistence for this case.

Earlier finding: Resolved. The current diff adds an agreement test demonstrating that Git ignores included core.worktree; the earlier blocking finding is not raised again.

Testing record: Sufficient. The description documents built real-CLI runs and output/exit-code comparisons. Older verification commits are a note, not a blocker; no additional provider × agent matrix is required.

Review was read-only. Nothing from the PR was run, built, or installed.

Git follows symlinks in core.worktree, so retargeting one moved the work tree
while every stamped file stayed the same, and a later run kept the old root.
The stamp now carries the real path of each worktree value in the stamped
config files; a value it cannot resolve leaves no stamp. Submodules, whose
config always names core.worktree, stay kept between runs.
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Re the [P3 nit] on retargeting a core.worktree symlink: fixed in 4101daa. The repository stamp now carries the real path each worktree = value resolves to, followed as written from the git directory, so a symlink before a .. or a .git symlink resolves the way git does. A value it cannot read (quoted, escaped, commented, unresolvable) leaves no stamp, so that repository asks git every run.

Tests compare teamai with git rev-parse --show-toplevel before and after a retarget: symlink to a subdirectory, to the checkout's own root, before a .., after another section header on the same line, and a relative value beside a git directory linked as .git. Each failed before the fix. A submodule (whose config always sets core.worktree) stays kept between runs.

@github-actions

Copy link
Copy Markdown
  • [P3 nit] Included core.bare changes leave the cached worktree list stale — src/utils/git-discovery.ts:125. Cache a linked checkout’s list with the main checkout’s core.bare supplied by an included file, then change that value. Only config and config.worktree are stamped, so subsequent runs reuse the old list and incorrectly include or omit the main checkout. Consider tracking included files or bypassing persistence in this case.

Earlier findings: Both resolved: the included-core.worktree agreement test addresses the first finding, and resolved worktree paths plus retargeting tests address the symlink finding.

Testing record: Sufficient. The description documents built real-CLI runs and output/exit-code comparisons. Older verification commits and coverage deferred to CI are not blockers.

Review was read-only. Nothing from the PR was run, built, or installed.

…orktree list

git worktree list reads the main entry's core.bare from the common config,
each checkout's own config.worktree and the GIT_CONFIG* variables. The stamp
now covers all three, and gives no stamp where that config includes another
file, which it does not follow.
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Re the [P3 nit] on included core.bare leaving the cached worktree list stale: fixed in 1f81044. The worktree-list stamp now covers every source git reads the main entry's core.bare from: the common config, each checkout's own config.worktree, and the GIT_CONFIG* variables. Any [include / [includeIf in those files, wherever it stands on its line, leaves no stamp, so that repository's list is asked of git each run.

Tests compare teamai's later-run list with git worktree list --porcelain after the main checkout is made bare through an included file, an include header after another header on the same line, the asking worktree's own config, and GIT_CONFIG_COUNT/KEY/VALUE. The same-line and environment cases failed before the fix. Global core.bare is not stamped: it only applies when the repository config lacks the key, which git init and git clone always write.

@github-actions

Copy link
Copy Markdown

No new findings in the specified PR diff.

  • Earlier findings: All three are resolved: included core.worktree, retargeted worktree symlinks, and included core.bare cache invalidation.
  • Testing record: Sufficient. The description documents built real-CLI verification with output/exit-code comparisons. Older verification commits are not blocking; additional provider/agent coverage is explicitly deferred to CI.

Review was read-only. Nothing from the PR was run, built, or installed.

@jeff-r2026
jeff-r2026 merged commit e0f7ecf into Tencent:main Oct 20, 2026
14 checks passed
Walternater pushed a commit to Walternater/teamai-cli that referenced this pull request Nov 2, 2026
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.

2 participants