From d7bc8fdba8550108b5982dc210b07da39f08100a Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Wed, 21 Oct 2026 14:53:36 -0300 Subject: [PATCH] feat(gs): sync pulls the dotfiles clone and installs 10-core.fish Hosts kept hand-edited copies of 10-core.fish and the Mac's Home Manager link went stale for weeks. gs sync now fast-forwards a clean dotfiles clone on main, then links 10-core.fish to it (backing up a host copy to .pre-gs) or runs home-manager switch when Home Manager owns the file. Dirty clones, other branches and missing clones are skipped with a status line. Refs #69 --- bin/gs | 60 +++++++++++++++++++++++++++++- fish/conf.d/99-local.fish.example | 4 ++ local/env.zsh.example | 3 ++ scripts/test-gs-sync.sh | 61 ++++++++++++++++++++++++++++++- 4 files changed, 126 insertions(+), 2 deletions(-) diff --git a/bin/gs b/bin/gs index 5229e72..7ce27e5 100755 --- a/bin/gs +++ b/bin/gs @@ -34,6 +34,10 @@ gs - gentle-shell on main, plus one disposable worktree per PR (workshop: local clones; elsewhere: GitHub) GS_SYNC_AGENTS=a,b scopes gentle-ai sync to those runtimes; GS_SKIP_AI_SYNC=1 skips it (binaries only) + Then pulls the dotfiles clone (clean, on main only) and + installs its fish 10-core.fish: symlink, or home-manager + switch when Home Manager owns it (GS_HM_CONFIG picks the + flake config); GS_SKIP_DOTFILES=1 skips this step gs ls List open worktrees gs done Remove the current worktree and its merged local branch (drops odd/tasks records; refuses if real work would be lost) @@ -265,8 +269,60 @@ changed_part() { if [ -n "$2" ] && [ -n "$3" ] && [ "$2" != "$3" ]; then printf ' %s (%s -> %s)' "$1" "$2" "$3"; fi } +# sync_dotfiles — pull the dotfiles clone and install its fish 10-core.fish, so +# hosts never keep hand-edited copies. Sets dot_change (" dotfiles (a -> b)"). +# Never fails the sync: every problem is a status line. +sync_dotfiles() { + local dir="${CORTEX_DOTFILES_DIR:-$HOME/dev/barbatdev/cortex/cortex-dotfiles}" + local target="$HOME/.config/fish/conf.d/10-core.fish" src branch old new out cfg + if [ "${GS_SKIP_DOTFILES:-}" = 1 ]; then + printf ' %-34s skipped (GS_SKIP_DOTFILES=1)\n' dotfiles; return 0 + fi + if [ ! -d "$dir/.git" ]; then + printf ' %-34s skipped (no clone at %s)\n' dotfiles "$dir"; return 0 + fi + # Never touch work in progress: only a clean clone on main follows origin. + if [ -n "$(git -C "$dir" status --porcelain 2>/dev/null)" ]; then + printf ' %-34s skipped (%s has local changes)\n' dotfiles "$dir"; return 0 + fi + branch=$(git -C "$dir" branch --show-current 2>/dev/null || true) + if [ "$branch" != main ]; then + printf ' %-34s skipped (%s is on %s, not main)\n' dotfiles "$dir" "${branch:-detached HEAD}"; return 0 + fi + old=$(git -C "$dir" rev-parse --short HEAD) + if ! out=$(git -C "$dir" pull -q --ff-only origin main 2>&1); then + printf ' %-34s FAILED\n' dotfiles; printf '%s\n' "$out" | sed 's/^/ /'; return 0 + fi + new=$(git -C "$dir" rev-parse --short HEAD) + printf ' %-34s ok\n' dotfiles + [ "$old" != "$new" ] && dot_change=" dotfiles ($old -> $new)" + + src="$dir/fish/conf.d/10-core.fish" + [ -f "$src" ] || return 0 + # Home Manager owns the file when it links into the nix store: switch, never relink. + if [ -L "$target" ] && case "$(readlink "$target")" in "${GS_HM_STORE_PREFIX:-/nix/store}"/*) true ;; *) false ;; esac; then + if ! command -v home-manager >/dev/null 2>&1; then + printf ' %-34s skipped (Home Manager link but no home-manager on PATH)\n' '10-core.fish'; return 0 + fi + if [ "$(uname)" = Darwin ]; then cfg=jbarbat; else cfg=jbarbat-linux; fi + run_quiet 'home-manager switch' home-manager switch --flake "$dir#${GS_HM_CONFIG:-$cfg}" -b pre-gs + return 0 + fi + if [ -L "$target" ] && [ "$(readlink "$target")" = "$src" ]; then + printf ' %-34s ok\n' '10-core.fish'; return 0 + fi + mkdir -p "$(dirname "$target")" + if [ -e "$target" ] || [ -L "$target" ]; then + local backup="$target.pre-gs" + { [ -e "$backup" ] || [ -L "$backup" ]; } && backup="$target.pre-gs.$(date +%s)" + mv "$target" "$backup" + fi + ln -s "$src" "$target" + printf ' %-34s linked\n' '10-core.fish' +} + cmd_sync() { - local ai_files="" repo shell bin="$HOME/.local/share/gentle-ai/main" reg="$HOME/.pi/gentle-ai/dev-binary.json" name + local ai_files="" dot_change="" repo shell bin="$HOME/.local/share/gentle-ai/main" reg="$HOME/.pi/gentle-ai/dev-binary.json" name # Versions before the sync power the closing message: restart only on change. local shell_before ai_before engram_before shell_before=$(shell_version); ai_before=$(ai_version); engram_before=$(engram_version) @@ -364,6 +420,7 @@ cmd_sync() { ai_files=$(sed -n 's/^Sync actions executed: \([0-9][0-9]*\) files* changed.*/\1/p' "$ai_out" | head -1) rm -f "$ai_out" fi + sync_dotfiles # gs sync is the only upgrade path; older homemade scripts drift the hosts apart. for name in "$HOME/.local/bin/gentle-maintainer-upgrade" "$HOME/.local/bin/gentle-sync.sh" \ /usr/bin/gentle-sync.sh /usr/local/bin/gentle-upgrade.sh /bin/gentle-upgrade.sh; do @@ -397,6 +454,7 @@ cmd_sync() { if [ -n "$ai_files" ] && [ "$ai_files" -gt 0 ]; then changed="$changed managed configs ($ai_files files)" fi + changed="$changed$dot_change" if [ -n "$changed" ]; then printf 'changed:%s\n Restart Pi to pick them up.\n' "$changed" else diff --git a/fish/conf.d/99-local.fish.example b/fish/conf.d/99-local.fish.example index ae37e17..679ac8a 100644 --- a/fish/conf.d/99-local.fish.example +++ b/fish/conf.d/99-local.fish.example @@ -14,3 +14,7 @@ # set -gx GS_SKIP_AI_SYNC 1 # 10-core.fish defaults GENTLE_AI_CHANNEL to beta; override only on a host that must differ: # set -gx GENTLE_AI_CHANNEL stable +# gs sync also pulls the dotfiles clone (clean, on main) and installs 10-core.fish. +# set -gx CORTEX_DOTFILES_DIR "$HOME/dev/barbatdev/cortex/cortex-dotfiles" +# set -gx GS_HM_CONFIG jbarbat-linux # flake config when Home Manager owns 10-core.fish +# set -gx GS_SKIP_DOTFILES 1 diff --git a/local/env.zsh.example b/local/env.zsh.example index 4766b42..a29a386 100644 --- a/local/env.zsh.example +++ b/local/env.zsh.example @@ -53,3 +53,6 @@ # export GS_SYNC_AGENTS="claude-code,opencode,pi" # Binaries only, no managed-config sync: # export GS_SKIP_AI_SYNC=1 +# gs sync also pulls the dotfiles clone (clean, on main) and installs 10-core.fish. +# export GS_HM_CONFIG="jbarbat" # flake config when Home Manager owns 10-core.fish +# export GS_SKIP_DOTFILES=1 diff --git a/scripts/test-gs-sync.sh b/scripts/test-gs-sync.sh index 2315c15..f98a219 100644 --- a/scripts/test-gs-sync.sh +++ b/scripts/test-gs-sync.sh @@ -32,7 +32,7 @@ esac exit 0 EOF chmod +x "$shims/_shim" -for name in gentle-shell pi go; do ln -s _shim "$shims/$name"; done +for name in gentle-shell pi go home-manager; do ln -s _shim "$shims/$name"; done ai_dir="$ws/ai"; mkdir -p "$ai_dir"; ln -s "$shims/_shim" "$ai_dir/gentle-ai" printf '{"schema":"gentle-pi.dev-binary/v1","path":"%s"}\n' "$ai_dir/gentle-ai" >"$home/.pi/gentle-ai/dev-binary.json" @@ -77,4 +77,63 @@ out=$(run_sync) || fail 'gs sync exited non-zero with legacy script' "$out" [[ "$out" == *"WARN: legacy upgrade script $home/.local/bin/gentle-maintainer-upgrade"* ]] || fail 'legacy script not flagged' "$out" [[ -f "$home/.local/bin/gentle-maintainer-upgrade" ]] || fail 'legacy script was removed' "$out" +# 6. Dotfiles step. A real local clone with a bare origin, so pull works offline. +dot="$home/dev/barbatdev/cortex/cortex-dotfiles" +origin="$ws/origin.git" +target="$home/.config/fish/conf.d/10-core.fish" +g() { git -c user.name=t -c user.email=t@t -c init.defaultBranch=main "$@"; } +g init -q --bare "$origin" +g clone -q "$origin" "$dot" 2>/dev/null +mkdir -p "$dot/fish/conf.d" && echo 'core v1' >"$dot/fish/conf.d/10-core.fish" +g -C "$dot" add -A && g -C "$dot" commit -qm v1 && g -C "$dot" push -q origin HEAD:main +dot_status() { grep -E '^ (dotfiles|10-core.fish) ' <<<"$1" || true; } + +# 6a. GS_SKIP_DOTFILES=1 skips everything. +out=$(run_sync GS_SKIP_DOTFILES=1) || fail 'gs sync failed with GS_SKIP_DOTFILES' "$out" +[[ "$(dot_status "$out")" == *'skipped (GS_SKIP_DOTFILES=1)'* ]] || fail 'dotfiles skip line missing' "$out" +[[ ! -e "$target" && ! -L "$target" ]] || fail '10-core.fish installed despite skip' "$out" + +# 6b. No clone: skip and keep going. +out=$(run_sync CORTEX_DOTFILES_DIR="$ws/nowhere") || fail 'gs sync failed without clone' "$out" +[[ "$(dot_status "$out")" == *"skipped (no clone at $ws/nowhere)"* ]] || fail 'no-clone skip missing' "$out" +[[ "$out" == *'versions:'* ]] || fail 'sync stopped after missing clone' "$out" + +# 6c. Upstream moves; the clean clone on main is pulled and the hint names it. +up="$ws/upstream"; g clone -q "$origin" "$up" +echo 'core v2' >"$up/fish/conf.d/10-core.fish"; g -C "$up" commit -qam v2 && g -C "$up" push -q origin HEAD:main +old=$(git -C "$dot" rev-parse --short HEAD); new=$(git -C "$up" rev-parse --short HEAD) +mkdir -p "$(dirname "$target")"; echo 'host copy' >"$target" +out=$(run_sync) || fail 'gs sync failed on dotfiles pull' "$out" +[[ "$(git -C "$dot" rev-parse --short HEAD)" == "$new" ]] || fail 'clone was not pulled' "$out" +[[ "$out" == *"dotfiles ($old -> $new)"* ]] || fail 'restart hint misses dotfiles change' "$out" +# 6d. A regular host copy is backed up byte-for-byte and replaced by a link to the clone. +[[ "$(<"$target.pre-gs")" == 'host copy' ]] || fail 'host copy not backed up' "$(ls -la "$(dirname "$target")")" +[[ -L "$target" && "$(readlink "$target")" == "$dot/fish/conf.d/10-core.fish" ]] || fail '10-core.fish not linked to the clone' "$(ls -la "$target")" +[[ "$(dot_status "$out")" == *'10-core.fish'*'linked'* ]] || fail 'link status line missing' "$out" +# 6e. Second run: nothing to pull, link already right. +out=$(run_sync) || fail 'second gs sync failed' "$out" +[[ "$(dot_status "$out")" == *'10-core.fish'*'ok'* ]] || fail 'second run should be a no-op' "$out" +[[ "$out" != *'dotfiles ('* ]] || fail 'no-op pull reported as change' "$out" + +# 6f. Local changes or another branch: clone untouched. +echo dirty >>"$dot/fish/conf.d/10-core.fish" +head=$(git -C "$dot" rev-parse HEAD) +out=$(run_sync) || fail 'gs sync failed with dirty clone' "$out" +[[ "$(dot_status "$out")" == *"skipped ($dot has local changes)"* ]] || fail 'dirty skip missing' "$out" +[[ "$(tail -1 "$dot/fish/conf.d/10-core.fish")" == dirty && "$(git -C "$dot" rev-parse HEAD)" == "$head" ]] || fail 'dirty clone was touched' "$out" +git -C "$dot" checkout -q -- . && git -C "$dot" checkout -q -b topic +out=$(run_sync) || fail 'gs sync failed on topic branch' "$out" +[[ "$(dot_status "$out")" == *"skipped ($dot is on topic, not main)"* ]] || fail 'branch skip missing' "$out" +git -C "$dot" checkout -q main + +# 6g. Home Manager owns 10-core.fish: switch through the flake, never relink. +store="$ws/nix/store"; mkdir -p "$store/hm/fish"; echo hm >"$store/hm/fish/10-core.fish" +rm -f "$target"; ln -s "$store/hm/fish/10-core.fish" "$target" +out=$(run_sync GS_HM_STORE_PREFIX="$store") || fail 'gs sync failed with Home Manager' "$out" +grep -q "^home-manager|switch --flake $dot#jbarbat" "$log" || fail 'home-manager switch not called with the flake' "$(cat "$log")" +grep -q "^home-manager|.* -b pre-gs" "$log" || fail 'home-manager switch without backup suffix' "$(cat "$log")" +[[ "$(readlink "$target")" == "$store/hm/fish/10-core.fish" ]] || fail 'Home Manager link was replaced' "$(ls -la "$target")" +out=$(run_sync GS_HM_STORE_PREFIX="$store" GS_HM_CONFIG=custom) || fail 'gs sync failed with GS_HM_CONFIG' "$out" +grep -q "^home-manager|switch --flake $dot#custom -b pre-gs" "$log" || fail 'GS_HM_CONFIG ignored' "$(cat "$log")" + echo "gs sync checks passed"