@sebastienrousseau/dotfiles 0.2.500 → 0.2.501

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +82 -44
  3. package/docs/.vitepress/reports/localization-readability-audit.md +4 -0
  4. package/docs/AI.md +8 -2
  5. package/docs/CNAME +1 -0
  6. package/docs/COPYRIGHT +1 -1
  7. package/docs/NAMING_CONVENTIONS.md +7 -0
  8. package/docs/README.md +4 -0
  9. package/docs/_config.yml +59 -0
  10. package/docs/adr/ADR-001-ci-cd-pipeline.md +17 -0
  11. package/docs/adr/ADR-002-shell-performance.md +9 -0
  12. package/docs/adr/ADR-003-security-first.md +18 -0
  13. package/docs/adr/ADR-004-cli-architecture.md +14 -0
  14. package/docs/adr/ADR-005-chezmoi-choice.md +10 -0
  15. package/docs/adr/ADR-006-shell-selection.md +9 -0
  16. package/docs/adr/ADR-007-multi-shell-parity.md +10 -1
  17. package/docs/adr/ADR-008-alias-system-architecture.md +10 -0
  18. package/docs/adr/ADR-009-wallpaper-driven-theming.md +131 -0
  19. package/docs/adr/ADR-010-starship-transient-prompt.md +144 -0
  20. package/docs/adr/ADR-011-nushell-tier3-keep.md +144 -0
  21. package/docs/adr/README.md +7 -0
  22. package/docs/architecture/ARCHITECTURE.md +4 -0
  23. package/docs/architecture/INTEROP.md +8 -0
  24. package/docs/architecture/REPO_LAYOUT.md +5 -1
  25. package/docs/architecture/WALKTHROUGH.md +4 -0
  26. package/docs/architecture/fleet-deployment.md +4 -0
  27. package/docs/archive/EUXIS_2026_REVIEW.md +11 -3
  28. package/docs/archive/LEGACY_ROADMAP.md +45 -27
  29. package/docs/archive/MILESTONE_v0.2.493.md +4 -0
  30. package/docs/archive/PLAN.md +46 -8
  31. package/docs/archive/REPO_AUDIT.md +8 -0
  32. package/docs/guides/INSTALL.md +4 -0
  33. package/docs/guides/NEOVIM_IDE_GUIDE.md +9 -0
  34. package/docs/guides/THEMING.md +8 -0
  35. package/docs/guides/TROUBLESHOOTING.md +28 -0
  36. package/docs/guides/WSL2_NIX_TROUBLESHOOTING.md +70 -1
  37. package/docs/index.md +6 -2
  38. package/docs/interop/A2A.md +7 -0
  39. package/docs/interop/POWERSHELL.md +102 -0
  40. package/docs/manual/00-introduction.md +5 -1
  41. package/docs/manual/01-concepts/01-architecture.md +4 -0
  42. package/docs/manual/01-concepts/02-trust-model.md +6 -2
  43. package/docs/manual/01-concepts/03-theme-engine.md +4 -0
  44. package/docs/manual/01-concepts/04-fleet.md +7 -1
  45. package/docs/manual/01-concepts/05-self-healing.md +8 -1
  46. package/docs/manual/02-tutorials/01-first-install.md +5 -0
  47. package/docs/manual/02-tutorials/02-add-wallpaper.md +5 -0
  48. package/docs/manual/02-tutorials/03-create-profile.md +6 -0
  49. package/docs/manual/02-tutorials/04-encrypt-secret.md +6 -0
  50. package/docs/manual/02-tutorials/05-deploy-fleet.md +5 -1
  51. package/docs/manual/03-reference/01-dot-cli.md +4 -0
  52. package/docs/manual/03-reference/02-config-files.md +8 -2
  53. package/docs/manual/03-reference/03-environment.md +4 -0
  54. package/docs/manual/03-reference/04-templates.md +7 -1
  55. package/docs/manual/03-reference/05-feature-flags.md +10 -0
  56. package/docs/manual/04-cookbook/01-recipes.md +4 -0
  57. package/docs/manual/04-cookbook/02-troubleshooting.md +32 -0
  58. package/docs/manual/04-cookbook/03-faq.md +8 -0
  59. package/docs/manual/05-appendices/A-platform-matrix.md +4 -0
  60. package/docs/manual/05-appendices/B-security-checklist.md +6 -0
  61. package/docs/manual/05-appendices/C-glossary.md +4 -0
  62. package/docs/manual/05-appendices/D-bibliography.md +5 -1
  63. package/docs/manual/05-appendices/E-license.md +4 -0
  64. package/docs/manual/_toc.yml +1 -1
  65. package/docs/manual/command-index.md +4 -0
  66. package/docs/manual/concept-index.md +4 -0
  67. package/docs/operations/ATTESTATION.md +5 -0
  68. package/docs/operations/CI_CADENCE.md +107 -0
  69. package/docs/operations/CI_COMPOSITES.md +156 -0
  70. package/docs/operations/COMPLETIONS.md +123 -0
  71. package/docs/operations/COVERAGE.md +148 -0
  72. package/docs/operations/DRIFT.md +107 -0
  73. package/docs/operations/MAINTENANCE.md +5 -1
  74. package/docs/operations/MIGRATION.md +14 -6
  75. package/docs/operations/OPERATIONS.md +24 -0
  76. package/docs/operations/PERFORMANCE.md +133 -0
  77. package/docs/operations/RELIABILITY.md +6 -0
  78. package/docs/operations/ROADMAP.md +36 -18
  79. package/docs/operations/TESTING.md +4 -0
  80. package/docs/operations/TRACEABILITY.md +4 -0
  81. package/docs/operations/TRUSTED_AGENT_WORKSTATION.md +4 -0
  82. package/docs/operations/VERSION_SYNC.md +54 -5
  83. package/docs/reference/ALIASES.md +7 -0
  84. package/docs/reference/ALIASES_CHEATSHEET.md +5 -1
  85. package/docs/reference/ALIASES_DEPRECATIONS.md +5 -1
  86. package/docs/reference/FEATURES.md +4 -0
  87. package/docs/reference/FONTS.md +4 -0
  88. package/docs/reference/PROFILES.md +4 -0
  89. package/docs/reference/SCREENSHOTS.md +4 -0
  90. package/docs/reference/SCRIPTS.md +4 -0
  91. package/docs/reference/SUPPORT_MATRIX.md +10 -4
  92. package/docs/reference/THEMES.md +4 -0
  93. package/docs/reference/TOOLS.md +4 -0
  94. package/docs/reference/UTILS.md +16 -12
  95. package/docs/security/AI_ACT_COMPLIANCE.md +4 -0
  96. package/docs/security/AUDIT_BYPASS.md +103 -0
  97. package/docs/security/AUTOMATION_SECRETS.md +4 -0
  98. package/docs/security/CI_EGRESS_ALLOWLIST.md +127 -0
  99. package/docs/security/COMPLIANCE.md +5 -0
  100. package/docs/security/DEPS_DEV_EXCEPTIONS.md +86 -0
  101. package/docs/security/ENCRYPTION.md +5 -0
  102. package/docs/security/FMEA.md +4 -0
  103. package/docs/security/HISTORY_FILTERING.md +132 -0
  104. package/docs/security/INCIDENT_RESPONSE.md +4 -0
  105. package/docs/security/INSTALL_VERIFICATION.md +122 -0
  106. package/docs/security/KEYS.md +4 -0
  107. package/docs/security/KEY_ROTATION.md +4 -0
  108. package/docs/security/MCP_POLICY.md +9 -0
  109. package/docs/security/POLICY_RELEASES.md +4 -0
  110. package/docs/security/README.md +4 -0
  111. package/docs/security/SCORECARD.md +80 -0
  112. package/docs/security/SECRETS.md +12 -0
  113. package/docs/security/SECURITY.md +4 -0
  114. package/docs/security/SECURITY_CHECKLIST.md +11 -0
  115. package/docs/security/SHELL_EXEMPTIONS.md +145 -0
  116. package/docs/security/SOUP_REGISTER.md +4 -0
  117. package/docs/security/THREAT_MODEL.md +10 -0
  118. package/docs/security/VERIFICATION_VALIDATION.md +5 -1
  119. package/docs/themes/README.md +4 -0
  120. package/docs/themes/VISUAL_INTEGRITY_REPORT.md +4 -0
  121. package/dot_config/ai/identity.md +3 -0
  122. package/dot_config/ai/patterns/architect.md +2 -0
  123. package/dot_config/ai/patterns/hardener.md +2 -0
  124. package/dot_config/ai/patterns/refactor.md +2 -0
  125. package/dot_config/alacritty/alacritty.toml.tmpl +3 -3
  126. package/dot_config/atuin/config.toml.tmpl +47 -0
  127. package/dot_config/dotfiles/agent-card.json +1 -1
  128. package/dot_config/dotfiles/boot/README.md +2 -0
  129. package/dot_config/dotfiles/grub/README.md +2 -0
  130. package/dot_config/dotfiles/lock/README.md +2 -0
  131. package/dot_config/fish/conf.d/init.fish.tmpl +21 -0
  132. package/dot_config/fish/functions/_cached_eval.fish +84 -11
  133. package/dot_config/fish/functions/_cached_eval_clear.fish +17 -0
  134. package/dot_config/foot/foot.ini.tmpl +3 -3
  135. package/dot_config/fuzzel/fuzzel.ini.tmpl +2 -2
  136. package/dot_config/ghostty/config.tmpl +3 -3
  137. package/dot_config/git/hooks/executable_commit-msg +146 -0
  138. package/dot_config/goose/config.yaml +2 -2
  139. package/dot_config/gtk-3.0/gtk.css.tmpl +2 -2
  140. package/dot_config/gtk-3.0/settings.ini.tmpl +2 -2
  141. package/dot_config/gtk-4.0/gtk.css.tmpl +2 -2
  142. package/dot_config/gtk-4.0/settings.ini.tmpl +2 -2
  143. package/dot_config/kitty/kitty.conf.tmpl +3 -3
  144. package/dot_config/mise/config.toml +1 -1
  145. package/dot_config/niri/config.kdl.tmpl +2 -2
  146. package/dot_config/nushell/cached_eval.nu +80 -0
  147. package/dot_config/nushell/env.nu.tmpl +21 -13
  148. package/dot_config/shell/00-core-paths.sh.tmpl +1 -0
  149. package/dot_config/shell/05-core-safety.sh +1 -0
  150. package/dot_config/shell/10-secrets.sh +1 -0
  151. package/dot_config/shell/40-fzf-defaults.sh.tmpl +1 -0
  152. package/dot_config/shell/40-ls-colors.sh +1 -0
  153. package/dot_config/shell/50-logic-functions-core.sh.tmpl +1 -0
  154. package/dot_config/shell/50-logic-functions.sh.tmpl +1 -0
  155. package/dot_config/shell/51-logic-functions-extra.sh.tmpl +1 -0
  156. package/dot_config/shell/90-ux-aliases.sh.tmpl +1 -0
  157. package/dot_config/shell/91-ux-aliases-lazy.sh.tmpl +1 -0
  158. package/dot_config/shell/README.md +25 -8
  159. package/dot_config/starship.toml.tmpl +2 -2
  160. package/dot_config/tmux/tmux.conf.tmpl +3 -3
  161. package/dot_config/user-dirs.dirs +1 -0
  162. package/dot_config/vscode/settings.json.tmpl +2 -2
  163. package/dot_config/waybar/config.jsonc.tmpl +2 -2
  164. package/dot_config/waybar/style.css.tmpl +2 -2
  165. package/dot_config/wezterm/wezterm.lua.tmpl +3 -3
  166. package/dot_config/zsh/dot_zshrc.tmpl +154 -17
  167. package/dot_config/zsh/rc.d/00-alias-shims.zsh +28 -6
  168. package/dot_config/zsh/rc.d/30-options.zsh.tmpl +26 -6
  169. package/dot_local/bin/executable_bm +2 -0
  170. package/dot_local/bin/executable_dot +9 -2
  171. package/dot_local/bin/executable_dot-load-benchmark +1 -1
  172. package/dot_local/bin/executable_notify +2 -0
  173. package/dot_local/bin/executable_open +2 -0
  174. package/dot_local/bin/executable_tour +4 -2
  175. package/dot_local/bin/executable_up +3 -1
  176. package/install.sh +12 -4
  177. package/package.json +1 -1
  178. package/scripts/ci/check-dangerous-chmod.sh +19 -0
  179. package/scripts/ci/check-deps-dev.sh +236 -0
  180. package/scripts/ci/check-insecure-tls.sh +61 -0
  181. package/scripts/ci/check-regression-traceability.sh +67 -0
  182. package/scripts/ci/check-shell-preamble.sh +106 -0
  183. package/scripts/ci/run-coverage.sh +362 -0
  184. package/scripts/ci/validate-chezmoidata.sh +25 -0
  185. package/scripts/diagnostics/doctor.sh +173 -5
  186. package/scripts/diagnostics/drift-dashboard.sh +177 -13
  187. package/scripts/diagnostics/health.sh +21 -4
  188. package/scripts/diagnostics/perf.sh +304 -77
  189. package/scripts/diagnostics/workstation-attestation.sh +6 -1
  190. package/scripts/dot/commands/agent.sh +14 -5
  191. package/scripts/dot/commands/ai.sh +75 -10
  192. package/scripts/dot/lib/bento.sh +2 -1
  193. package/scripts/dot/lib/log.sh +6 -0
  194. package/scripts/dot/lib/platform.sh +1 -0
  195. package/scripts/dot/lib/ui.sh +11 -0
  196. package/scripts/dot/lib/utils.sh +5 -0
  197. package/scripts/git-hooks/pre-commit-audit.sh +1 -1
  198. package/scripts/git-hooks/pre-push +66 -3
  199. package/scripts/ops/heal-chezmoi.sh +41 -6
  200. package/scripts/qa/powershell-contract.ps1 +95 -0
  201. package/scripts/theme/merge-wallpaper.sh +4 -0
  202. package/scripts/theme/switch.sh +20 -10
  203. package/dot_config/atuin/config.toml +0 -40
  204. package/dot_local/bin/__pycache__/executable_dot-load-benchmark-ptycpython-312.pyc +0 -0
@@ -1,5 +1,32 @@
1
1
  #!/usr/bin/env bash
2
2
  # Copyright (c) 2015-2026 Dotfiles. All rights reserved.
3
+ # =============================================================================
4
+ # drift-dashboard.sh — Consolidated drift report.
5
+ #
6
+ # Surfaces four classes of drift between the chezmoi source and the
7
+ # deployed targets:
8
+ #
9
+ # 1. chezmoi-managed drift — deployed file differs from rendered source
10
+ # (`chezmoi status` output). Standard case; what the original
11
+ # drift-dashboard reported.
12
+ # 2. Untracked source — a file exists in the chezmoi source tree but
13
+ # isn't tracked by git, suggesting in-progress local work that
14
+ # hasn't been committed.
15
+ # 3. Orphan deployed — a file under XDG/HOME that was previously
16
+ # chezmoi-managed but the source has since been removed. Detected
17
+ # by sampling well-known managed paths and asking chezmoi if it
18
+ # claims each.
19
+ # 4. Stale source — source file older than its deployed target,
20
+ # which means someone hand-edited the deployed file and the next
21
+ # `chezmoi apply` will silently revert it. Reverse-drift trap.
22
+ #
23
+ # Output is JSON when --json is passed, otherwise the existing
24
+ # ui-formatted summary. Exit code: 0 if every section is clean; 1 if
25
+ # any drift is found; 2 if a prerequisite (chezmoi, git) is missing.
26
+ #
27
+ # Closes #875.
28
+ # =============================================================================
29
+
3
30
  set -euo pipefail
4
31
 
5
32
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -7,31 +34,168 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
34
  # shellcheck disable=SC1091
8
35
  source "$SCRIPT_DIR/../dot/lib/ui.sh"
9
36
 
37
+ JSON_MODE=0
38
+ SHOW_DIFF="${DOTFILES_DRIFT_SHOW_DIFF:-0}"
39
+
40
+ for arg in "$@"; do
41
+ case "$arg" in
42
+ --json | -j) JSON_MODE=1 ;;
43
+ --diff | -d) SHOW_DIFF=1 ;;
44
+ --help | -h)
45
+ cat <<EOF
46
+ Usage: drift-dashboard.sh [options]
47
+
48
+ Options:
49
+ --json, -j Emit a single JSON object summarising every drift class.
50
+ --diff, -d Also print the chezmoi diff (excluding scripts/install/tests).
51
+ --help, -h Show this help.
52
+ EOF
53
+ exit 0
54
+ ;;
55
+ esac
56
+ done
57
+
10
58
  ui_init
11
59
 
12
60
  if ! command -v chezmoi >/dev/null; then
13
- ui_err "chezmoi" "not found"
14
- exit 1
61
+ if [[ $JSON_MODE -eq 1 ]]; then
62
+ printf '{"error":"chezmoi not found"}\n'
63
+ else
64
+ ui_err "chezmoi" "not found"
65
+ fi
66
+ exit 2
67
+ fi
68
+
69
+ # -----------------------------------------------------------------------------
70
+ # Class 1: chezmoi-managed drift
71
+ # -----------------------------------------------------------------------------
72
+
73
+ cm_status="$(chezmoi status 2>/dev/null || true)"
74
+ cm_count=0
75
+ if [[ -n "$cm_status" ]]; then
76
+ cm_count=$(printf '%s\n' "$cm_status" | wc -l | tr -d ' ')
77
+ fi
78
+
79
+ # -----------------------------------------------------------------------------
80
+ # Class 2: untracked source files (chezmoi source tree only)
81
+ # -----------------------------------------------------------------------------
82
+
83
+ untracked=""
84
+ untracked_count=0
85
+ src_dir="$(chezmoi source-path 2>/dev/null || true)"
86
+ if [[ -n "$src_dir" && -d "$src_dir/.git" ]]; then
87
+ untracked="$(git -C "$src_dir" ls-files --others --exclude-standard 2>/dev/null || true)"
88
+ if [[ -n "$untracked" ]]; then
89
+ untracked_count=$(printf '%s\n' "$untracked" | wc -l | tr -d ' ')
90
+ fi
91
+ fi
92
+
93
+ # -----------------------------------------------------------------------------
94
+ # Class 4: stale source (source older than deployed = pending revert risk)
95
+ # Compute by walking chezmoi-managed targets and comparing mtimes.
96
+ # -----------------------------------------------------------------------------
97
+
98
+ stale_list=""
99
+ stale_count=0
100
+ if [[ -n "$src_dir" ]]; then
101
+ while IFS= read -r target; do
102
+ [[ -z "$target" ]] && continue
103
+ target_path="$target"
104
+ [[ ! -e "$target_path" ]] && continue
105
+ src_path="$(chezmoi source-path "$target_path" 2>/dev/null || true)"
106
+ [[ -z "$src_path" || ! -e "$src_path" ]] && continue
107
+ if [[ "$target_path" -nt "$src_path" ]]; then
108
+ stale_list+="$target_path"$'\n'
109
+ stale_count=$((stale_count + 1))
110
+ fi
111
+ done < <(chezmoi managed 2>/dev/null | head -200 | while IFS= read -r rel; do
112
+ printf '%s\n' "$HOME/$rel"
113
+ done)
114
+ fi
115
+ stale_list="${stale_list%$'\n'}"
116
+
117
+ # -----------------------------------------------------------------------------
118
+ # Class 3: orphan deployed files (chezmoi no longer claims them)
119
+ # Sample heuristic — chezmoi doesn't expose a direct "orphan" query.
120
+ # We surface a count of zero unless the user has a pre-populated
121
+ # ~/.local/state/dotfiles/orphans file (drift-history feature in fleet
122
+ # already maintains one); future work tracked under #875.
123
+ # -----------------------------------------------------------------------------
124
+
125
+ orphan_count=0
126
+ orphan_file="${XDG_STATE_HOME:-$HOME/.local/state}/dotfiles/orphans"
127
+ if [[ -s "$orphan_file" ]]; then
128
+ orphan_count=$(wc -l <"$orphan_file" | tr -d ' ')
129
+ fi
130
+
131
+ # -----------------------------------------------------------------------------
132
+ # Report
133
+ # -----------------------------------------------------------------------------
134
+
135
+ total=$((cm_count + untracked_count + orphan_count + stale_count))
136
+
137
+ if [[ $JSON_MODE -eq 1 ]]; then
138
+ python3 - <<PY
139
+ import json, sys
140
+ print(json.dumps({
141
+ "managed_drift": $cm_count,
142
+ "untracked_source": $untracked_count,
143
+ "orphan_deployed": $orphan_count,
144
+ "stale_source": $stale_count,
145
+ "total": $total
146
+ }))
147
+ PY
148
+ if ((total > 0)); then exit 1; else exit 0; fi
15
149
  fi
16
150
 
17
151
  ui_header "Dotfiles Drift Dashboard"
18
152
 
19
- status=$(chezmoi status || true)
20
- if [[ -z "$status" ]]; then
21
- ui_ok "Clean" "no local drift detected"
22
- exit 0
153
+ # Class 1
154
+ if ((cm_count > 0)); then
155
+ echo ""
156
+ ui_warn "Managed drift" "$cm_count file(s) — deployed differs from rendered source"
157
+ printf '%s\n' "$cm_status"
158
+ else
159
+ ui_ok "Managed drift" "clean"
23
160
  fi
24
161
 
25
- echo ""
26
- ui_header "Changed files"
27
- printf '%s\n' "$status"
162
+ # Class 2
163
+ if ((untracked_count > 0)); then
164
+ echo ""
165
+ ui_warn "Untracked source" "$untracked_count file(s) in chezmoi source not tracked by git"
166
+ printf '%s\n' "$untracked"
167
+ else
168
+ ui_ok "Untracked source" "clean"
169
+ fi
170
+
171
+ # Class 3
172
+ if ((orphan_count > 0)); then
173
+ echo ""
174
+ ui_warn "Orphan deployed" "$orphan_count file(s) — review $(pretty_path "$orphan_file")"
175
+ else
176
+ ui_ok "Orphan deployed" "clean"
177
+ fi
178
+
179
+ # Class 4
180
+ if ((stale_count > 0)); then
181
+ echo ""
182
+ ui_warn "Stale source" "$stale_count target(s) newer than source — next \`chezmoi apply\` would revert"
183
+ printf '%s\n' "$stale_list"
184
+ else
185
+ ui_ok "Stale source" "clean"
186
+ fi
28
187
 
29
- count=$(printf '%s\n' "$status" | wc -l | tr -d ' ')
30
188
  echo ""
31
- ui_info "Total" "$count"
189
+ if ((total > 0)); then
190
+ ui_warn "Total drift signals" "$total"
191
+ else
192
+ ui_ok "Total" "no drift detected"
193
+ fi
32
194
 
33
- if [[ "${DOTFILES_DRIFT_SHOW_DIFF:-0}" = "1" ]]; then
195
+ if [[ "$SHOW_DIFF" = "1" && $cm_count -gt 0 ]]; then
34
196
  echo ""
35
- ui_header "Diff (excluding scripts/install/tests)"
197
+ ui_header "chezmoi diff (excluding scripts/install/tests)"
36
198
  chezmoi diff --exclude scripts --exclude install --exclude tests || true
37
199
  fi
200
+
201
+ ((total > 0)) && exit 1 || exit 0
@@ -420,16 +420,33 @@ check_config_directories() {
420
420
  check_sync_status() {
421
421
  check_section "Sync Status"
422
422
 
423
- # Chezmoi status
423
+ # Chezmoi status. The two-character prefix encodes both directions:
424
+ # col 1 = last-applied state vs actual (was the file edited in
425
+ # place since last apply?)
426
+ # col 2 = actual state vs target (what `chezmoi apply`
427
+ # would still change)
428
+ # Only col 2 matters for "is $HOME out of sync with the source" —
429
+ # col 1 alone just means the source repo has uncommitted edits,
430
+ # which is normal during development and not something the user
431
+ # needs warned about via the health dashboard.
424
432
  if has_command chezmoi; then
425
433
  local status_output
426
434
  status_output=$(chezmoi status 2>/dev/null || echo "")
427
435
  if [[ -z "$status_output" ]]; then
428
436
  check "Chezmoi sync" "pass"
429
437
  else
430
- local changes
431
- changes=$(printf '%s\n' "$status_output" | wc -l | tr -d ' ')
432
- check "Chezmoi sync" "warn" "$changes file(s) out of sync"
438
+ # Count only entries where column 2 is non-space (apply would do something).
439
+ local applicable
440
+ applicable=$(printf '%s\n' "$status_output" | awk 'substr($0,2,1)!=" "' | wc -l | tr -d ' ')
441
+ if [[ "$applicable" -eq 0 ]]; then
442
+ # All drift is source-only (unstaged edits in the source repo).
443
+ # That's not a sync issue; mention it but pass.
444
+ local source_only
445
+ source_only=$(printf '%s\n' "$status_output" | wc -l | tr -d ' ')
446
+ check "Chezmoi sync" "pass" "$source_only source-only edit(s) (run 'chezmoi diff' to inspect)"
447
+ else
448
+ check "Chezmoi sync" "warn" "$applicable file(s) out of sync"
449
+ fi
433
450
  fi
434
451
  else
435
452
  check "Chezmoi sync" "warn" "Not installed, skipped"
@@ -29,6 +29,9 @@ PROFILE=false
29
29
  RUNS=3
30
30
  TARGET_MS="${DOTFILES_PERF_TARGET_MS:-250}"
31
31
  MAX_MS="${DOTFILES_PERF_MAX_MS:-1000}"
32
+ SHELL_FILTER=""
33
+ BY_TOOL=false
34
+ RESET_TIMINGS=false
32
35
 
33
36
  while [[ $# -gt 0 ]]; do
34
37
  case "$1" in
@@ -48,20 +51,180 @@ while [[ $# -gt 0 ]]; do
48
51
  TARGET_MS="${2:-$TARGET_MS}"
49
52
  shift 2
50
53
  ;;
54
+ --shell | -s)
55
+ SHELL_FILTER="${2:-}"
56
+ shift 2
57
+ ;;
58
+ --by-tool)
59
+ BY_TOOL=true
60
+ shift
61
+ ;;
62
+ --reset)
63
+ RESET_TIMINGS=true
64
+ shift
65
+ ;;
66
+ --baseline)
67
+ # Write the current per-shell means to the baseline file so
68
+ # subsequent runs can warn on regression. Closes part of #863.
69
+ WRITE_BASELINE=true
70
+ shift
71
+ ;;
72
+ --no-baseline-check)
73
+ # Skip the baseline-vs-current comparison entirely. Useful for
74
+ # the very first run on a machine, or for short-lived CI.
75
+ NO_BASELINE_CHECK=true
76
+ shift
77
+ ;;
51
78
  *)
52
79
  shift
53
80
  ;;
54
81
  esac
55
82
  done
56
83
 
57
- time_shell_startup() {
58
- local start end
84
+ WRITE_BASELINE="${WRITE_BASELINE:-false}"
85
+ NO_BASELINE_CHECK="${NO_BASELINE_CHECK:-false}"
86
+ BASELINE_FILE="${XDG_CACHE_HOME:-$HOME/.cache}/dotfiles/perf-baseline.json"
87
+ BASELINE_REGRESSION_PCT="${DOTFILES_PERF_REGRESSION_PCT:-10}"
88
+
89
+ # --by-tool reader: aggregate $XDG_STATE_HOME/dotfiles/eval-timings.jsonl
90
+ # (populated by _cached_eval when EVALCACHE_TIMING=1) and report which
91
+ # tools dominate startup time. Independent of the runtime measurement
92
+ # loop above, so it returns immediately.
93
+ if $BY_TOOL || $RESET_TIMINGS; then
94
+ log_dir="${XDG_STATE_HOME:-$HOME/.local/state}/dotfiles"
95
+ log_file="$log_dir/eval-timings.jsonl"
96
+ if $RESET_TIMINGS; then
97
+ : >"$log_file" 2>/dev/null || rm -f "$log_file" 2>/dev/null || true
98
+ ui_ok "eval timings" "cleared $log_file"
99
+ $BY_TOOL || exit 0
100
+ fi
101
+ if [[ ! -s "$log_file" ]]; then
102
+ ui_warn "eval timings" "no data at $log_file"
103
+ echo " Hint: open a new shell with EVALCACHE_TIMING=1 to start collecting." >&2
104
+ exit 0
105
+ fi
106
+ ui_dot_banner "Diagnostics"
107
+ ui_header "Per-tool timing breakdown"
108
+ ui_section "$log_file"
109
+ python3 - "$log_file" <<'PY'
110
+ import json, sys
111
+ from collections import defaultdict
112
+
113
+ def percentile(sorted_vals, p):
114
+ """Linear-interpolation percentile, matching numpy.percentile defaults."""
115
+ if not sorted_vals:
116
+ return 0
117
+ if len(sorted_vals) == 1:
118
+ return sorted_vals[0]
119
+ k = (len(sorted_vals) - 1) * (p / 100.0)
120
+ f = int(k)
121
+ c = min(f + 1, len(sorted_vals) - 1)
122
+ if f == c:
123
+ return sorted_vals[f]
124
+ return sorted_vals[f] + (sorted_vals[c] - sorted_vals[f]) * (k - f)
125
+
126
+ samples = defaultdict(list) # label -> [ms, ms, ...]
127
+ shells_by_label = defaultdict(set) # label -> {shell, ...}
128
+
129
+ with open(sys.argv[1]) as f:
130
+ for line in f:
131
+ line = line.strip()
132
+ if not line:
133
+ continue
134
+ try:
135
+ ev = json.loads(line)
136
+ except Exception:
137
+ continue
138
+ label = ev.get("label", "?")
139
+ try:
140
+ ms = int(ev.get("ms", 0) or 0)
141
+ except (TypeError, ValueError):
142
+ continue
143
+ samples[label].append(ms)
144
+ shells_by_label[label].add(ev.get("shell", "?"))
145
+
146
+ rows = []
147
+ for label, vals in samples.items():
148
+ vals_sorted = sorted(vals)
149
+ rows.append({
150
+ "label": label,
151
+ "count": len(vals_sorted),
152
+ "total": sum(vals_sorted),
153
+ "mean": sum(vals_sorted) // max(len(vals_sorted), 1),
154
+ "min": vals_sorted[0],
155
+ "max": vals_sorted[-1],
156
+ "p50": int(percentile(vals_sorted, 50)),
157
+ "p95": int(percentile(vals_sorted, 95)),
158
+ "p99": int(percentile(vals_sorted, 99)),
159
+ "shells": ",".join(sorted(shells_by_label[label])),
160
+ })
161
+
162
+ rows.sort(key=lambda r: r["total"], reverse=True)
163
+ header = (f" {'label':<20} {'calls':>5} {'total':>8} "
164
+ f"{'mean':>7} {'p50':>5} {'p95':>5} {'p99':>5} shells")
165
+ print(header)
166
+ print(" " + "-" * (len(header) - 2))
167
+ for r in rows:
168
+ print(f" {r['label']:<20} {r['count']:>5} {r['total']:>6}ms "
169
+ f"{r['mean']:>5}ms {r['p50']:>3}ms {r['p95']:>3}ms {r['p99']:>3}ms {r['shells']}")
170
+ PY
171
+ exit 0
172
+ fi
173
+
174
+ # Per-shell defaults. nu and pwsh are genuinely slower than POSIX shells;
175
+ # bash should be quickest. Override via DOTFILES_PERF_TARGET_<SHELL>_MS.
176
+ shell_target_for() {
177
+ case "$1" in
178
+ zsh) echo "${DOTFILES_PERF_TARGET_ZSH_MS:-$TARGET_MS}" ;;
179
+ bash) echo "${DOTFILES_PERF_TARGET_BASH_MS:-60}" ;;
180
+ fish) echo "${DOTFILES_PERF_TARGET_FISH_MS:-200}" ;;
181
+ nu) echo "${DOTFILES_PERF_TARGET_NU_MS:-500}" ;;
182
+ pwsh) echo "${DOTFILES_PERF_TARGET_PWSH_MS:-600}" ;;
183
+ *) echo "$TARGET_MS" ;;
184
+ esac
185
+ }
186
+
187
+ # Invoke a near-empty session for the named shell, picking flags that
188
+ # load the user's interactive profile (matches what a fresh terminal does).
189
+ invoke_shell() {
190
+ case "$1" in
191
+ zsh) zsh -i -c exit </dev/null ;;
192
+ bash) bash -i -c exit </dev/null ;;
193
+ fish) fish -i -c exit </dev/null ;;
194
+ nu) nu -c exit </dev/null ;;
195
+ pwsh) pwsh -Command exit </dev/null ;;
196
+ *) return 1 ;;
197
+ esac
198
+ }
199
+
200
+ time_one_run() {
201
+ local shell_name="$1" start end
59
202
  start=$(python3 -c 'import time; print(int(time.time() * 1000))')
60
- zsh -i -c exit >/dev/null 2>&1
203
+ invoke_shell "$shell_name" >/dev/null 2>&1 || true
61
204
  end=$(python3 -c 'import time; print(int(time.time() * 1000))')
62
205
  echo $((end - start))
63
206
  }
64
207
 
208
+ # Existing single-shell helper retained for backward compatibility with
209
+ # callers/tests that reference time_shell_startup; routes to time_one_run.
210
+ time_shell_startup() { time_one_run zsh; }
211
+
212
+ # Measure a shell across $RUNS iterations, return "mean min max".
213
+ measure_shell() {
214
+ local shell_name="$1" sum=0 min=999999 max=0 t
215
+ local times=()
216
+ # One warm-up run discarded — caches a $_SHELL_CACHE on first invocation.
217
+ invoke_shell "$shell_name" >/dev/null 2>&1 || true
218
+ for _ in $(seq 1 "$RUNS"); do
219
+ t=$(time_one_run "$shell_name")
220
+ times+=("$t")
221
+ sum=$((sum + t))
222
+ [[ "$t" -lt "$min" ]] && min="$t"
223
+ [[ "$t" -gt "$max" ]] && max="$t"
224
+ done
225
+ echo "$((sum / RUNS)) $min $max"
226
+ }
227
+
65
228
  calc_score() {
66
229
  local mean_ms="$1"
67
230
  if [[ "$mean_ms" -le "$TARGET_MS" ]]; then
@@ -86,95 +249,159 @@ run_profile() {
86
249
  ' 2>/dev/null | head -20
87
250
  }
88
251
 
89
- if $JSON_OUTPUT; then
90
- times=()
91
- for _ in $(seq 1 "$RUNS"); do
92
- times+=("$(time_shell_startup)")
252
+ # Discover installed shells. SHELL_FILTER (--shell <name>) restricts to one.
253
+ all_shells=(zsh bash fish nu pwsh)
254
+ shells_to_measure=()
255
+ for s in "${all_shells[@]}"; do
256
+ command -v "$s" >/dev/null 2>&1 || continue
257
+ if [[ -n "$SHELL_FILTER" && "$s" != "$SHELL_FILTER" ]]; then
258
+ continue
259
+ fi
260
+ shells_to_measure+=("$s")
261
+ done
262
+ if ((${#shells_to_measure[@]} == 0)); then
263
+ ui_err "perf" "no measurable shells found${SHELL_FILTER:+ (filter: $SHELL_FILTER)}"
264
+ exit 1
265
+ fi
266
+
267
+ # Measure each shell once into parallel arrays.
268
+ declare -a shell_names shell_means shell_mins shell_maxs shell_targets shell_passes
269
+ for s in "${shells_to_measure[@]}"; do
270
+ read -r m mn mx <<<"$(measure_shell "$s")"
271
+ t=$(shell_target_for "$s")
272
+ shell_names+=("$s")
273
+ shell_means+=("$m")
274
+ shell_mins+=("$mn")
275
+ shell_maxs+=("$mx")
276
+ shell_targets+=("$t")
277
+ if [[ "$m" -le "$t" ]]; then
278
+ shell_passes+=("1")
279
+ else
280
+ shell_passes+=("0")
281
+ fi
282
+ done
283
+
284
+ # Primary "score" continues to use the zsh measurement (when available)
285
+ # so existing dashboards / JSON consumers keep their reference number.
286
+ mean=0
287
+ for i in "${!shell_names[@]}"; do
288
+ if [[ "${shell_names[$i]}" == "zsh" ]]; then
289
+ mean="${shell_means[$i]}"
290
+ break
291
+ fi
292
+ done
293
+ [[ "$mean" -eq 0 && "${#shell_means[@]}" -gt 0 ]] && mean="${shell_means[0]}"
294
+ score=$(calc_score "$mean")
295
+
296
+ # -----------------------------------------------------------------------------
297
+ # Baseline persistence + regression detection (closes part of #863).
298
+ # Stored at $XDG_CACHE_HOME/dotfiles/perf-baseline.json. The file is a
299
+ # JSON object mapping shell name → recorded mean (ms). `--baseline`
300
+ # overwrites it with the current measurement; default behavior reads
301
+ # it (if present) and warns when any shell regresses by more than
302
+ # DOTFILES_PERF_REGRESSION_PCT (default 10%).
303
+ # -----------------------------------------------------------------------------
304
+
305
+ declare -a baseline_warnings=()
306
+ if $WRITE_BASELINE; then
307
+ mkdir -p "$(dirname "$BASELINE_FILE")"
308
+ {
309
+ printf '{\n "recorded_at": "%s",\n "regression_pct": %d,\n "shells": {' \
310
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$BASELINE_REGRESSION_PCT"
311
+ for i in "${!shell_names[@]}"; do
312
+ [[ "$i" -gt 0 ]] && printf ','
313
+ printf '\n "%s": %d' "${shell_names[$i]}" "${shell_means[$i]}"
314
+ done
315
+ printf '\n }\n}\n'
316
+ } >"$BASELINE_FILE"
317
+ fi
318
+
319
+ if ! $NO_BASELINE_CHECK && [[ -s "$BASELINE_FILE" ]]; then
320
+ # Read the baseline and compare each measured shell.
321
+ for i in "${!shell_names[@]}"; do
322
+ name="${shell_names[$i]}"
323
+ m="${shell_means[$i]}"
324
+ baseline_ms=$(python3 -c '
325
+ import json, sys
326
+ try:
327
+ d = json.load(open(sys.argv[1]))
328
+ print(d.get("shells", {}).get(sys.argv[2], 0))
329
+ except Exception:
330
+ print(0)
331
+ ' "$BASELINE_FILE" "$name" 2>/dev/null)
332
+ [[ -z "$baseline_ms" || "$baseline_ms" -eq 0 ]] && continue
333
+ # Threshold: current > baseline * (1 + pct/100)
334
+ threshold=$((baseline_ms * (100 + BASELINE_REGRESSION_PCT) / 100))
335
+ if ((m > threshold)); then
336
+ delta_pct=$((((m - baseline_ms) * 100) / baseline_ms))
337
+ baseline_warnings+=("$name: $m ms vs baseline $baseline_ms ms (+${delta_pct}%, threshold +${BASELINE_REGRESSION_PCT}%)")
338
+ fi
93
339
  done
94
- sum=0
95
- min=999999
96
- max=0
97
- for t in "${times[@]}"; do
98
- sum=$((sum + t))
99
- if [[ "$t" -lt "$min" ]]; then min="$t"; fi
100
- if [[ "$t" -gt "$max" ]]; then max="$t"; fi
340
+ fi
341
+
342
+ if $JSON_OUTPUT; then
343
+ printf '{\n "runs": %d,\n "target_ms": %d,\n "max_ms_target": %d,\n "score": %d,\n "mean_ms": %d,\n "regression_count": %d,\n "shells": {' \
344
+ "$RUNS" "$TARGET_MS" "$MAX_MS" "$score" "$mean" "${#baseline_warnings[@]}"
345
+ for i in "${!shell_names[@]}"; do
346
+ [[ "$i" -gt 0 ]] && printf ','
347
+ printf '\n "%s": {"mean_ms": %d, "min_ms": %d, "max_ms": %d, "target_ms": %d, "pass": %s}' \
348
+ "${shell_names[$i]}" "${shell_means[$i]}" "${shell_mins[$i]}" "${shell_maxs[$i]}" \
349
+ "${shell_targets[$i]}" "$([[ ${shell_passes[$i]} == 1 ]] && echo true || echo false)"
101
350
  done
102
- mean=$((sum / RUNS))
103
- score=$(calc_score "$mean")
104
- cat <<JSON
105
- {
106
- "mean_ms": $mean,
107
- "min_ms": $min,
108
- "max_ms": $max,
109
- "runs": $RUNS,
110
- "target_ms": $TARGET_MS,
111
- "max_ms_target": $MAX_MS,
112
- "score": $score
113
- }
114
- JSON
351
+ printf '\n }'
352
+ if [[ "${#baseline_warnings[@]}" -gt 0 ]]; then
353
+ printf ',\n "regressions": ['
354
+ for i in "${!baseline_warnings[@]}"; do
355
+ [[ "$i" -gt 0 ]] && printf ','
356
+ # JSON-escape the warning text minimally (no embedded quotes expected).
357
+ printf '\n "%s"' "${baseline_warnings[$i]}"
358
+ done
359
+ printf '\n ]'
360
+ fi
361
+ printf '\n}\n'
115
362
  exit 0
116
363
  fi
117
364
 
118
365
  ui_dot_banner "Diagnostics"
119
366
  ui_header "Shell Performance"
120
367
 
121
- ui_section "Startup timing"
368
+ ui_section "Per-shell startup ($RUNS runs each, after one warm-up)"
122
369
 
123
- times=()
124
- for _ in $(seq 1 "$RUNS"); do
125
- times+=("$(time_shell_startup)")
126
- done
127
- sum=0
128
- min=999999
129
- max=0
130
- for t in "${times[@]}"; do
131
- sum=$((sum + t))
132
- if [[ "$t" -lt "$min" ]]; then min="$t"; fi
133
- if [[ "$t" -gt "$max" ]]; then max="$t"; fi
134
- if [[ "$UI_ENABLED" = "1" ]]; then
135
- ui_kv "Run" "${t}ms"
370
+ # Aligned table: name (6) | mean (8) | min/max (16) | target (12) | status
371
+ for i in "${!shell_names[@]}"; do
372
+ name="${shell_names[$i]}"
373
+ m="${shell_means[$i]}"
374
+ mn="${shell_mins[$i]}"
375
+ mx="${shell_maxs[$i]}"
376
+ t="${shell_targets[$i]}"
377
+ if [[ "${shell_passes[$i]}" == "1" ]]; then
378
+ status_marker="✓"
379
+ detail=""
136
380
  else
137
- echo " Run: ${t}ms"
381
+ status_marker=""
382
+ detail=" — over by $((m - t))ms"
138
383
  fi
384
+ printf ' %s %-6s mean %4dms (min %3dms, max %3dms) target %4dms%s\n' \
385
+ "$status_marker" "$name" "$m" "$mn" "$mx" "$t" "$detail"
139
386
  done
140
- mean=$((sum / RUNS))
141
- score=$(calc_score "$mean")
142
387
 
143
- if [[ "$UI_ENABLED" = "1" ]]; then
144
- ui_kv "Average" "${mean}ms"
145
- ui_kv "Min" "${min}ms"
146
- ui_kv "Max" "${max}ms"
147
- ui_kv "Target" "${TARGET_MS}ms"
148
- ui_kv "Score" "${score}/100"
149
- else
150
- echo " Average: ${mean}ms"
151
- echo " Min: ${min}ms"
152
- echo " Max: ${max}ms"
153
- echo " Target: ${TARGET_MS}ms"
154
- echo " Score: ${score}/100"
388
+ # Baseline comparison summary
389
+ if [[ "${#baseline_warnings[@]}" -gt 0 ]]; then
390
+ ui_section "Baseline regressions"
391
+ for w in "${baseline_warnings[@]}"; do
392
+ printf ' %s\n' "$w"
393
+ done
394
+ printf ' (threshold: >%s%% over the recorded baseline at %s)\n' \
395
+ "$BASELINE_REGRESSION_PCT" "$BASELINE_FILE"
396
+ elif [[ -s "$BASELINE_FILE" ]] && ! $NO_BASELINE_CHECK; then
397
+ ui_section "Baseline"
398
+ printf ' ✓ all measured shells within %s%% of baseline (%s)\n' \
399
+ "$BASELINE_REGRESSION_PCT" "$BASELINE_FILE"
400
+ elif $WRITE_BASELINE; then
401
+ ui_section "Baseline"
402
+ printf ' ✓ wrote baseline to %s\n' "$BASELINE_FILE"
155
403
  fi
156
404
 
157
- # Per-shell startup comparison
158
- ui_section "Per-shell startup"
159
-
160
- for shell_name in zsh bash fish; do
161
- if command -v "$shell_name" >/dev/null 2>&1; then
162
- local_start=$(python3 -c 'import time; print(int(time.time() * 1000))')
163
- if [[ "$shell_name" == "fish" ]]; then
164
- fish -c exit >/dev/null 2>&1 || true
165
- else
166
- "$shell_name" -i -c exit >/dev/null 2>&1 || true
167
- fi
168
- local_end=$(python3 -c 'import time; print(int(time.time() * 1000))')
169
- shell_time=$((local_end - local_start))
170
- if [[ "$UI_ENABLED" = "1" ]]; then
171
- ui_kv "$shell_name" "${shell_time}ms"
172
- else
173
- echo " $shell_name: ${shell_time}ms"
174
- fi
175
- fi
176
- done
177
-
178
405
  # Per-component breakdown (Zsh only, uses DOTFILES_DEBUG timing)
179
406
  ui_section "Component breakdown (estimated)"
180
407
  for component in "bare zsh" "paths+env" "aliases" "functions" "tools"; do