From bb8859f8b7c73a951e069e25bb924067c46e1a42 Mon Sep 17 00:00:00 2001 From: Davood Tofighi Date: Wed, 23 Sep 2026 11:32:16 -0600 Subject: [PATCH 1/2] fix(claude-sync): commit what it adds instead of relying on chezmoi autoCommit chezmoi commits by itself only when git.autoCommit is configured. Without it, chezmoi add/re-add only staged into the source dir, the local-ahead check saw no commit, and claude-sync printed 'nothing to push (already synced)' while changes piled up uncommitted (50 on this machine before a manual commit). - _claude_sync_commit stages and commits only the ~/.claude source subtree, so unrelated staged dotfile edits stay staged. - Default and --no-push now commit; --add commits before pushing. - Header now names flow-cli as the source (it said chezmoi-managed). - New tests/test-claude-sync.zsh (6 cases; fake chezmoi + real git against a throwaway bare origin), registered in run-all.sh. Co-Authored-By: Claude Opus 5.5 --- tests/run-all.sh | 1 + tests/test-claude-sync.zsh | 177 ++++++++++++++++++++++++++++++++++ zsh/functions/claude-sync.zsh | 53 ++++++---- 3 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 tests/test-claude-sync.zsh diff --git a/tests/run-all.sh b/tests/run-all.sh index 1c4fe7851..2443bfe39 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -163,6 +163,7 @@ run_test ./tests/test-em-move-restore.zsh run_test ./tests/test-em-undo.zsh run_test ./tests/test-tok.zsh run_test ./tests/test-tok-sync.zsh +run_test ./tests/test-claude-sync.zsh echo "" echo "Core command tests:" diff --git a/tests/test-claude-sync.zsh b/tests/test-claude-sync.zsh new file mode 100644 index 000000000..5da4aa501 --- /dev/null +++ b/tests/test-claude-sync.zsh @@ -0,0 +1,177 @@ +#!/usr/bin/env zsh +# ══════════════════════════════════════════════════════════════════════════════ +# UNIT TEST SUITE - CLAUDE-SYNC (zsh/functions/claude-sync.zsh) +# ══════════════════════════════════════════════════════════════════════════════ +# +# Purpose: claude-sync must commit what it adds. chezmoi commits by itself only +# when git.autoCommit is configured; without it, `chezmoi add` only +# stages into the source dir and the changes never reach the remote. +# +# `chezmoi` is replaced by a shell function that copies files into a throwaway +# source repo whose origin is a local bare repo, so real git commit/push runs +# without touching the user's dotfiles. +# +# Standalone: `zsh tests/test-claude-sync.zsh` exits 0 when green. +# ══════════════════════════════════════════════════════════════════════════════ + +SCRIPT_DIR="${0:A:h}" +PROJECT_ROOT="${SCRIPT_DIR:h}" +source "$SCRIPT_DIR/test-framework.zsh" || { echo "ERROR: Cannot source test-framework.zsh"; exit 1 } + +export FLOW_QUIET=1 +export FLOW_ATLAS_ENABLED=no + +# ────────────────────────────────────────────────────────────────────────────── +# SETUP / CLEANUP +# ────────────────────────────────────────────────────────────────────────────── + +REAL_HOME="$HOME" +SANDBOX="" +SRC="" +ORIGIN="" + +setup() { + source "$PROJECT_ROOT/zsh/functions/claude-sync.zsh" 2>/dev/null +} + +# Fake chezmoi: source-path, managed, add/re-add (copy into $SRC/dot_claude). +chezmoi() { + local sub="$1"; shift + case "$sub" in + source-path) + if [[ -n "$1" ]]; then print -r -- "$SRC/dot_claude"; else print -r -- "$SRC"; fi ;; + managed) + print -r -- ".claude"; print -r -- ".claude/CLAUDE.md" ;; + add|re-add) + local p rel + for p in "$@"; do + rel="${p#$HOME/.claude/}" + if [[ -d "$p" ]]; then + mkdir -p "$SRC/dot_claude/$rel" && cp -R "$p/." "$SRC/dot_claude/$rel/" + else + mkdir -p "$SRC/dot_claude/${rel:h}" && cp "$p" "$SRC/dot_claude/$rel" + fi + done ;; + diff) ;; + esac +} + +make_sandbox() { + SANDBOX="$(mktemp -d -t claude-sync-test.XXXXXX)" + ORIGIN="$SANDBOX/origin.git" + SRC="$SANDBOX/src" + export HOME="$SANDBOX/home" + mkdir -p "$HOME/.claude/projects/p1/memory" + print -r -- "# global" > "$HOME/.claude/CLAUDE.md" + print -r -- "fact one" > "$HOME/.claude/projects/p1/memory/one.md" + + git init -q --bare -b main "$ORIGIN" + git init -q -b main "$SRC" + git -C "$SRC" config user.email test@example.com + git -C "$SRC" config user.name test + print -r -- "unrelated" > "$SRC/dot_zshrc" + git -C "$SRC" add -A && git -C "$SRC" commit -q -m init + git -C "$SRC" remote add origin "$ORIGIN" + git -C "$SRC" push -q -u origin main 2>/dev/null +} + +teardown_sandbox() { + export HOME="$REAL_HOME" + [[ -n "$SANDBOX" && -d "$SANDBOX" ]] && rm -rf "$SANDBOX" + SANDBOX="" SRC="" ORIGIN="" +} + +# ────────────────────────────────────────────────────────────────────────────── +# TESTS +# ────────────────────────────────────────────────────────────────────────────── + +test_function_exists() { + test_case "claude-sync function exists" + assert_function_exists "claude-sync" || return + test_pass +} + +test_default_commits_and_pushes_new_memory() { + test_case "default run commits new memory files and pushes them" + make_sandbox + local out; out=$(claude-sync 2>&1) + local remote_files; remote_files=$(git -C "$ORIGIN" ls-tree -r --name-only main) + local dirty; dirty=$(git -C "$SRC" status --porcelain) + teardown_sandbox + assert_contains "$remote_files" "dot_claude/projects/p1/memory/one.md" || return + assert_empty "$dirty" "source repo should be clean after sync" || return + assert_contains "$out" "pushed" || return + test_pass +} + +test_no_push_commits_locally_only() { + test_case "--no-push commits but leaves origin untouched" + make_sandbox + claude-sync --no-push >/dev/null 2>&1 + local ahead; ahead=$(git -C "$SRC" rev-list --count origin/main..HEAD) + local remote_files; remote_files=$(git -C "$ORIGIN" ls-tree -r --name-only main) + teardown_sandbox + assert_equals "$ahead" "1" || return + assert_not_contains "$remote_files" "one.md" || return + test_pass +} + +test_second_run_reports_already_synced() { + test_case "second run with no changes makes no commit" + make_sandbox + claude-sync >/dev/null 2>&1 + local before; before=$(git -C "$SRC" rev-parse HEAD) + local out; out=$(claude-sync 2>&1) + local after; after=$(git -C "$SRC" rev-parse HEAD) + teardown_sandbox + assert_equals "$after" "$before" || return + assert_contains "$out" "nothing to push" || return + test_pass +} + +test_does_not_commit_unrelated_dotfiles() { + test_case "unrelated staged dotfile changes are not swept into the commit" + make_sandbox + print -r -- "edited" > "$SRC/dot_zshrc" + git -C "$SRC" add dot_zshrc + claude-sync --no-push >/dev/null 2>&1 + local committed; committed=$(git -C "$SRC" log --name-only --format= origin/main..HEAD) + local still_staged; still_staged=$(git -C "$SRC" diff --cached --name-only) + teardown_sandbox + assert_contains "$committed" "one.md" || return + assert_not_contains "$committed" "dot_zshrc" || return + assert_contains "$still_staged" "dot_zshrc" || return + test_pass +} + +test_add_commits_before_push() { + test_case "--add commits the new path before pushing" + make_sandbox + print -r -- "{}" > "$HOME/.claude/extra.json" + claude-sync --add "$HOME/.claude/extra.json" >/dev/null 2>&1 + local remote_files; remote_files=$(git -C "$ORIGIN" ls-tree -r --name-only main) + teardown_sandbox + assert_contains "$remote_files" "dot_claude/extra.json" || return + test_pass +} + +# ────────────────────────────────────────────────────────────────────────────── +# MAIN +# ────────────────────────────────────────────────────────────────────────────── + +main() { + test_suite_start "claude-sync" + setup + + test_function_exists + test_default_commits_and_pushes_new_memory + test_no_push_commits_locally_only + test_second_run_reports_already_synced + test_does_not_commit_unrelated_dotfiles + test_add_commits_before_push + + test_suite_end + exit $? +} + +main "$@" diff --git a/zsh/functions/claude-sync.zsh b/zsh/functions/claude-sync.zsh index 6bbfea2a7..8b00e3e9d 100644 --- a/zsh/functions/claude-sync.zsh +++ b/zsh/functions/claude-sync.zsh @@ -4,15 +4,26 @@ # Keeps ~/.claude/CLAUDE.md and per-project memory dirs in sync with the # dotfiles repo (Data-Wise/dotfiles via chezmoi). # -# Source: managed by chezmoi at dot_config/zsh/functions/claude-sync.zsh -# Apply destination: ~/.config/zsh/functions/claude-sync.zsh +# Source: flow-cli zsh/functions/claude-sync.zsh (~/.config/zsh links to flow-cli/zsh) # # Usage: -# claude-sync # Re-add tracked files + push (silent if nothing changed) +# claude-sync # Re-add tracked files, commit, push (silent if nothing changed) # claude-sync --status # Show drift between live ~ and chezmoi source # claude-sync --no-push # Re-add + commit only, don't push # claude-sync --add # Add a new ~/.claude/... path to tracking +# Commit what chezmoi staged under the ~/.claude source dir. chezmoi commits by +# itself only when git.autoCommit is configured, which is not assumed here. +# Commits only that subtree, so unrelated staged dotfile edits stay staged. +_claude_sync_commit() { + local src="$1" claude_src + claude_src=$(chezmoi source-path ~/.claude 2>/dev/null) + [[ -n "$claude_src" ]] || return 1 + git -C "$src" add -A -- "$claude_src" || return 1 + git -C "$src" diff --cached --quiet -- "$claude_src" && return 0 + git -C "$src" commit -q -m "chore(claude): claude-sync $(date +%Y-%m-%d)" -- "$claude_src" +} + claude-sync() { local action="default" local extra_path="" @@ -46,8 +57,11 @@ claude-sync() { echo "claude-sync: --add needs an existing path" >&2 return 2 fi + local add_src + add_src=$(chezmoi source-path 2>/dev/null) chezmoi add "$extra_path" && \ - git -C "$(chezmoi source-path)" push origin main + _claude_sync_commit "$add_src" && \ + git -C "$add_src" push origin main return $? ;; esac @@ -71,21 +85,26 @@ claude-sync() { # Directories: use `add` (recurses, picks up new files written this session) (( ${#dir_targets[@]} > 0 )) && chezmoi add "${dir_targets[@]}" 2>/dev/null + # Use `git -C "$(chezmoi source-path)"` instead of `chezmoi cd &&` — + # `chezmoi cd` is interactive-only and silently no-ops in a sourced + # function, which previously caused git commands to run in the wrong + # repo's CWD. + local src + src=$(chezmoi source-path 2>/dev/null) + if [[ -z "$src" || ! -d "$src/.git" ]]; then + echo "claude-sync: can't locate chezmoi source git repo" >&2 + return 1 + fi + + if ! _claude_sync_commit "$src"; then + echo "claude-sync: commit failed in $src" >&2 + return 1 + fi + # Push if commits were made if [[ "$action" != "no-push" ]]; then - # Use `git -C "$(chezmoi source-path)"` instead of `chezmoi cd &&` — - # `chezmoi cd` is interactive-only and silently no-ops in a sourced - # function, which previously caused git commands to run in the wrong - # repo's CWD. - local src - src=$(chezmoi source-path 2>/dev/null) - if [[ -z "$src" || ! -d "$src/.git" ]]; then - echo "claude-sync: can't locate chezmoi source git repo" >&2 - return 1 - fi - - # Local-ahead check: chezmoi auto-commits even when content didn't - # change, so this can be true with no actual remote-bound work. + # Local-ahead check: covers this run's commit and any earlier + # unpushed ones (e.g. from --no-push). if [[ -z "$(git -C "$src" log origin/main..HEAD --oneline 2>/dev/null)" ]]; then echo "claude-sync: nothing to push (already synced)" return 0 From 1d0e1184420992e74ef285115f90bf5972636894 Mon Sep 17 00:00:00 2001 From: Davood Tofighi Date: Wed, 23 Sep 2026 11:42:46 -0600 Subject: [PATCH 2/2] fix(claude-sync): --add commits the added path, not only ~/.claude A --add of a path outside ~/.claude was staged by chezmoi but never committed, then the push reported success with nothing new. The commit helper now takes the target whose source path it commits. Co-Authored-By: Claude Opus 5.5 --- tests/test-claude-sync.zsh | 22 ++++++++++++++++++---- zsh/functions/claude-sync.zsh | 18 +++++++++--------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/tests/test-claude-sync.zsh b/tests/test-claude-sync.zsh index 5da4aa501..76895a2d2 100644 --- a/tests/test-claude-sync.zsh +++ b/tests/test-claude-sync.zsh @@ -39,17 +39,19 @@ chezmoi() { local sub="$1"; shift case "$sub" in source-path) - if [[ -n "$1" ]]; then print -r -- "$SRC/dot_claude"; else print -r -- "$SRC"; fi ;; + if [[ -z "$1" ]]; then print -r -- "$SRC" + elif [[ "$1" == "$HOME/.claude"* ]]; then print -r -- "$SRC/dot_claude${1#$HOME/.claude}" + else print -r -- "$SRC/dot_${1#$HOME/.}"; fi ;; managed) print -r -- ".claude"; print -r -- ".claude/CLAUDE.md" ;; add|re-add) local p rel for p in "$@"; do - rel="${p#$HOME/.claude/}" + rel="$(chezmoi source-path "$p")" if [[ -d "$p" ]]; then - mkdir -p "$SRC/dot_claude/$rel" && cp -R "$p/." "$SRC/dot_claude/$rel/" + mkdir -p "$rel" && cp -R "$p/." "$rel/" else - mkdir -p "$SRC/dot_claude/${rel:h}" && cp "$p" "$SRC/dot_claude/$rel" + mkdir -p "${rel:h}" && cp "$p" "$rel" fi done ;; diff) ;; @@ -155,6 +157,17 @@ test_add_commits_before_push() { test_pass } +test_add_outside_claude_commits_that_path() { + test_case "--add of a path outside ~/.claude still commits it" + make_sandbox + print -r -- "set -o vi" > "$HOME/.inputrc" + claude-sync --add "$HOME/.inputrc" >/dev/null 2>&1 + local remote_files; remote_files=$(git -C "$ORIGIN" ls-tree -r --name-only main) + teardown_sandbox + assert_contains "$remote_files" "dot_inputrc" || return + test_pass +} + # ────────────────────────────────────────────────────────────────────────────── # MAIN # ────────────────────────────────────────────────────────────────────────────── @@ -169,6 +182,7 @@ main() { test_second_run_reports_already_synced test_does_not_commit_unrelated_dotfiles test_add_commits_before_push + test_add_outside_claude_commits_that_path test_suite_end exit $? diff --git a/zsh/functions/claude-sync.zsh b/zsh/functions/claude-sync.zsh index 8b00e3e9d..6c928f045 100644 --- a/zsh/functions/claude-sync.zsh +++ b/zsh/functions/claude-sync.zsh @@ -12,16 +12,16 @@ # claude-sync --no-push # Re-add + commit only, don't push # claude-sync --add # Add a new ~/.claude/... path to tracking -# Commit what chezmoi staged under the ~/.claude source dir. chezmoi commits by +# Commit what chezmoi staged for (default ~/.claude). chezmoi commits by # itself only when git.autoCommit is configured, which is not assumed here. -# Commits only that subtree, so unrelated staged dotfile edits stay staged. +# Commits only that target's source path, so unrelated staged dotfile edits stay staged. _claude_sync_commit() { - local src="$1" claude_src - claude_src=$(chezmoi source-path ~/.claude 2>/dev/null) - [[ -n "$claude_src" ]] || return 1 - git -C "$src" add -A -- "$claude_src" || return 1 - git -C "$src" diff --cached --quiet -- "$claude_src" && return 0 - git -C "$src" commit -q -m "chore(claude): claude-sync $(date +%Y-%m-%d)" -- "$claude_src" + local src="$1" target="${2:-$HOME/.claude}" target_src + target_src=$(chezmoi source-path "$target" 2>/dev/null) + [[ -n "$target_src" ]] || return 1 + git -C "$src" add -A -- "$target_src" || return 1 + git -C "$src" diff --cached --quiet -- "$target_src" && return 0 + git -C "$src" commit -q -m "chore(claude): claude-sync $(date +%Y-%m-%d)" -- "$target_src" } claude-sync() { @@ -60,7 +60,7 @@ claude-sync() { local add_src add_src=$(chezmoi source-path 2>/dev/null) chezmoi add "$extra_path" && \ - _claude_sync_commit "$add_src" && \ + _claude_sync_commit "$add_src" "$extra_path" && \ git -C "$add_src" push origin main return $? ;;