Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tests/run-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
191 changes: 191 additions & 0 deletions tests/test-claude-sync.zsh
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#!/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 [[ -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="$(chezmoi source-path "$p")"
if [[ -d "$p" ]]; then
mkdir -p "$rel" && cp -R "$p/." "$rel/"
else
mkdir -p "${rel:h}" && cp "$p" "$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
}

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
# ──────────────────────────────────────────────────────────────────────────────

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_add_outside_claude_commits_that_path

test_suite_end
exit $?
}

main "$@"
53 changes: 36 additions & 17 deletions zsh/functions/claude-sync.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> # Add a new ~/.claude/... path to tracking

# Commit what chezmoi staged for <target> (default ~/.claude). chezmoi commits by
# itself only when git.autoCommit is configured, which is not assumed here.
# Commits only that target's source path, so unrelated staged dotfile edits stay staged.
_claude_sync_commit() {
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() {
local action="default"
local extra_path=""
Expand Down Expand Up @@ -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" "$extra_path" && \
git -C "$add_src" push origin main
return $?
;;
esac
Expand All @@ -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
Expand Down
Loading