From 888e02a558f01e4de400c880a73e831f14732415 Mon Sep 17 00:00:00 2001 From: jiminu Date: Tue, 25 Aug 2026 13:15:55 +0900 Subject: [PATCH 1/2] fix: manage ~/.vimrc as a bounded block in user-owned config --- AGENTS.md | 7 ++-- lib/commands/install.sh | 1 + lib/commands/update.sh | 1 + lib/managed.sh | 28 +++++++++++++--- lib/resources.sh | 2 +- scripts/macos-configuration-e2e.sh | 31 +++++++++++------- tests/managed_install_test.bash | 52 +++++++++++++++++++++++++++++- 7 files changed, 100 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fd779a7..0ee6203 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,9 +59,10 @@ Managed defaults are copied under `~/.config/selfishell`. User-facing integration uses either managed links or bounded blocks in user-owned configuration files. -`~/.zshrc` and `~/.zprofile` remain user-owned, each holding one bounded -Selfishell block. `~/.zshrc`'s block sources the managed platform entrypoint; -personal aliases, exports, PATH entries, and functions belong outside it. On +`~/.zshrc`, `~/.zprofile`, and `~/.vimrc` remain user-owned, each holding +one bounded Selfishell block. `~/.zshrc`'s block sources the managed platform +entrypoint; `~/.vimrc`'s block sources the managed Vim entrypoint; personal +aliases, exports, PATH entries, and functions belong outside it. On Ubuntu/WSL, `~/.zshenv` is also user-owned and contains only Selfishell's bounded zshenv block (`skip_global_compinit=1`); macOS `~/.zshenv` is not managed. diff --git a/lib/commands/install.sh b/lib/commands/install.sh index 93c3e60..7c4f9d3 100644 --- a/lib/commands/install.sh +++ b/lib/commands/install.sh @@ -240,6 +240,7 @@ command_install() { managed_preflight_block_target user-zshenv "$HOME/.zshenv" "$assume_yes" "$dry_run" || return ;; esac + managed_preflight_block_target user-vimrc "$HOME/.vimrc" "$assume_yes" "$dry_run" || return profile_load "$profile" if [[ "$profile" == "developer" ]]; then preflight_mise_global_config || return diff --git a/lib/commands/update.sh b/lib/commands/update.sh index 04af84c..9063460 100644 --- a/lib/commands/update.sh +++ b/lib/commands/update.sh @@ -54,6 +54,7 @@ update_tools_and_configuration() { managed_preflight_block_target user-zshenv "$HOME/.zshenv" "$assume_yes" "$dry_run" || return ;; esac + managed_preflight_block_target user-vimrc "$HOME/.vimrc" "$assume_yes" "$dry_run" || return if [[ "$platform" == "macos" && "$ghostty_enabled" == "1" ]]; then managed_preflight_block_target user-ghostty \ diff --git a/lib/managed.sh b/lib/managed.sh index d341860..5fe76a3 100644 --- a/lib/managed.sh +++ b/lib/managed.sh @@ -176,6 +176,7 @@ managed_select_block_conflict_action() { managed_block_definition() { local resource="$1" + MANAGED_BLOCK_COMMENT='#' case "$resource" in user-zshrc) MANAGED_BLOCK_LABEL='Selfishell initialize' @@ -207,6 +208,16 @@ fi' # To override a Selfishell default above, add it to user.ghostty instead. config-file = ?user.ghostty" ;; + user-vimrc) + MANAGED_BLOCK_LABEL='Selfishell vimrc' + MANAGED_BLOCK_COMMENT='"' + # shellcheck disable=SC2016 # Literal for vim to evaluate at startup, not now. + MANAGED_BLOCK_BODY='if !empty($XDG_CONFIG_HOME) && filereadable($XDG_CONFIG_HOME . "/selfishell/vim/vimrc") + source $XDG_CONFIG_HOME/selfishell/vim/vimrc +elseif filereadable(expand("~/.config/selfishell/vim/vimrc")) + source ~/.config/selfishell/vim/vimrc +endif' + ;; *) cli_error "Unknown managed block resource: $resource" return "$SELFISHELL_EXIT_ERROR" @@ -215,18 +226,25 @@ config-file = ?user.ghostty" } managed_block_begin() { - printf '# >>> %s >>>\n' "$1" + local label="$1" + local comment="${2:-${MANAGED_BLOCK_COMMENT:-#}}" + printf '%s >>> %s >>>\n' "$comment" "$label" } managed_block_end() { - printf '# <<< %s <<<\n' "$1" + local label="$1" + local comment="${2:-${MANAGED_BLOCK_COMMENT:-#}}" + printf '%s <<< %s <<<\n' "$comment" "$label" } managed_block_content() { local resource="$1" managed_block_definition "$resource" || return - printf '%s\n%s\n%s\n' "$(managed_block_begin "$MANAGED_BLOCK_LABEL")" "$MANAGED_BLOCK_BODY" "$(managed_block_end "$MANAGED_BLOCK_LABEL")" + printf '%s\n%s\n%s\n' \ + "$(managed_block_begin "$MANAGED_BLOCK_LABEL" "$MANAGED_BLOCK_COMMENT")" \ + "$MANAGED_BLOCK_BODY" \ + "$(managed_block_end "$MANAGED_BLOCK_LABEL" "$MANAGED_BLOCK_COMMENT")" } # Sets MANAGED_BLOCK_STATUS to absent/malformed/intact based purely on marker @@ -252,8 +270,8 @@ managed_inspect_block() { managed_block_definition "$resource" || return [[ -f "$target_file" && ! -L "$target_file" ]] || return 0 - begin_marker="$(managed_block_begin "$MANAGED_BLOCK_LABEL")" - end_marker="$(managed_block_end "$MANAGED_BLOCK_LABEL")" + begin_marker="$(managed_block_begin "$MANAGED_BLOCK_LABEL" "$MANAGED_BLOCK_COMMENT")" + end_marker="$(managed_block_end "$MANAGED_BLOCK_LABEL" "$MANAGED_BLOCK_COMMENT")" metadata="$(LC_ALL=C awk -v begin="$begin_marker" -v end="$end_marker" -v label="$MANAGED_BLOCK_LABEL" ' BEGIN { offset = 0; begin_count = 0; end_count = 0; related_count = 0; start = 0; finish = 0 } { diff --git a/lib/resources.sh b/lib/resources.sh index 2140c6b..10248eb 100644 --- a/lib/resources.sh +++ b/lib/resources.sh @@ -32,7 +32,7 @@ block user-zshrc $HOME/.zshrc - block user-zprofile $HOME/.zprofile - block user-zshenv $HOME/.zshenv - link user-starship ${XDG_CONFIG_HOME:-$HOME/.config}/starship.toml $SELFISHELL_CONFIG_DIR/starship.toml -link user-vimrc ${XDG_CONFIG_HOME:-$HOME/.config}/vim/vimrc $SELFISHELL_CONFIG_DIR/vim/vimrc +block user-vimrc $HOME/.vimrc - link user-nvim ${XDG_CONFIG_HOME:-$HOME/.config}/nvim $SELFISHELL_CONFIG_DIR/nvim block user-ghostty ${XDG_CONFIG_HOME:-$HOME/.config}/ghostty/config.ghostty - EOF diff --git a/scripts/macos-configuration-e2e.sh b/scripts/macos-configuration-e2e.sh index e0c93a0..112753b 100644 --- a/scripts/macos-configuration-e2e.sh +++ b/scripts/macos-configuration-e2e.sh @@ -71,7 +71,7 @@ publish_fixture "$NEXT_VERSION" run_primary_lifecycle() { local home="$TEST_ROOT/home-primary" local prefix="$home/.local" - local backups_before backups_after loader_count starship_backup vimrc_backup + local backups_before backups_after loader_count starship_backup local zshrc_mode_before zshrc_mode_after export HOME="$home" @@ -93,11 +93,10 @@ run_primary_lifecycle() { # target) must be moved to a timestamped backup, not silently replaced. printf 'format = "user starship config"\n' >"$XDG_CONFIG_HOME/starship.toml" - # A dangling symlink at another managed link target must also be treated - # as user data (the -L check in managed_install_link covers this), not - # silently followed or deleted. - mkdir -p "$XDG_CONFIG_HOME/vim" - ln -s /nonexistent-target "$XDG_CONFIG_HOME/vim/vimrc" + # Pre-existing user .vimrc with CRLF style, verifying block insertion and restoration. + printf 'set nocompatible\r\nset background=dark' >"$HOME/.vimrc" + local vimrc_before + vimrc_before="$(cat "$HOME/.vimrc")" SELFISHELL_RELEASE_ROOT="file://$RELEASE_ROOT" \ bash "$ROOT_DIR/install.sh" --version "$INITIAL_VERSION" --prefix "$prefix" \ @@ -107,9 +106,13 @@ run_primary_lifecycle() { [[ -f "$HOME/.zshrc" && ! -L "$HOME/.zshrc" ]] || fail "install did not leave .zshrc as a regular, user-owned file" loader_count="$(grep -Fc '# >>> Selfishell initialize >>>' "$HOME/.zshrc")" [[ "$loader_count" == 1 ]] || fail "install did not add exactly one loader block (found $loader_count)" + [[ -f "$HOME/.vimrc" && ! -L "$HOME/.vimrc" ]] || fail "install did not leave .vimrc as a regular, user-owned file" + local vimrc_block_count + vimrc_block_count="$(grep -Fc '" >>> Selfishell vimrc >>>' "$HOME/.vimrc")" + [[ "$vimrc_block_count" == 1 ]] || fail "install did not add exactly one vimrc block (found $vimrc_block_count)" [[ -d "$XDG_CONFIG_HOME/selfishell" ]] || fail "managed configuration was not created under XDG_CONFIG_HOME" [[ -d "$XDG_STATE_HOME/selfishell" ]] || fail "managed state was not created under XDG_STATE_HOME" - # The managed *links* (starship.toml, vim/vimrc, mise's conf.d entry) live + # The managed *links* (starship.toml, nvim, mise's conf.d entry) live # under $HOME itself, pointing into the copied $XDG_CONFIG_HOME/selfishell # tree -- never directly at the source checkout that this script runs from. while IFS= read -r -d '' link; do @@ -125,8 +128,6 @@ run_primary_lifecycle() { starship_backup="$(find "$XDG_CONFIG_HOME" -maxdepth 1 -name 'starship.toml.backup.*')" [[ -n "$starship_backup" ]] || fail "a pre-existing starship.toml was not backed up" grep -Fq 'user starship config' "$starship_backup" || fail "the starship.toml backup does not hold the original content" - vimrc_backup="$(find "$XDG_CONFIG_HOME/vim" -maxdepth 1 -name 'vimrc.backup.*')" - [[ -n "$vimrc_backup" ]] || fail "a pre-existing dangling vimrc symlink was not treated as user data" zshrc_mode_after="$(stat -f '%Lp' "$HOME/.zshrc")" [[ "$zshrc_mode_after" == "$zshrc_mode_before" ]] || @@ -139,6 +140,8 @@ run_primary_lifecycle() { "$prefix/bin/selfishell" install --profile minimal --skip-packages --yes >/dev/null loader_count="$(grep -Fc '# >>> Selfishell initialize >>>' "$HOME/.zshrc")" [[ "$loader_count" == 1 ]] || fail "a second install duplicated the loader block (found $loader_count)" + vimrc_block_count="$(grep -Fc '" >>> Selfishell vimrc >>>' "$HOME/.vimrc")" + [[ "$vimrc_block_count" == 1 ]] || fail "a second install duplicated the vimrc block (found $vimrc_block_count)" backups_after="$(find "$XDG_CONFIG_HOME" "$XDG_STATE_HOME" -name '*.backup.*' | sort)" [[ "$backups_before" == "$backups_after" ]] || fail "a second install created an unnecessary backup" assert_managed_resources_clean "$prefix" "after an idempotent reinstall" @@ -158,21 +161,25 @@ run_primary_lifecycle() { "$prefix/bin/selfishell" update --tools-only --skip-packages --yes >/dev/null loader_count="$(grep -Fc '# >>> Selfishell initialize >>>' "$HOME/.zshrc")" [[ "$loader_count" == 1 ]] || fail "update duplicated the loader block (found $loader_count)" - # The loader block is prepended, so the user's original content is a + vimrc_block_count="$(grep -Fc '" >>> Selfishell vimrc >>>' "$HOME/.vimrc")" + [[ "$vimrc_block_count" == 1 ]] || fail "update duplicated the vimrc block (found $vimrc_block_count)" + # The loader/vimrc block is prepended, so the user's original content is a # suffix of the file, not a prefix. [[ "$(cat "$HOME/.zshrc")" == *"$zshrc_before" ]] || fail "update did not preserve the user's original .zshrc content" + [[ "$(cat "$HOME/.vimrc")" == *"$vimrc_before" ]] || + fail "update did not preserve the user's original .vimrc content" assert_managed_resources_clean "$prefix" "after a configuration update" # --- uninstall --restore --- "$prefix/bin/selfishell" uninstall --restore --yes >/dev/null [[ "$(cat "$HOME/.zshrc")" == "$zshrc_before" ]] || fail "uninstall --restore did not preserve the user's .zshrc byte-for-byte" + [[ "$(cat "$HOME/.vimrc")" == "$vimrc_before" ]] || + fail "uninstall --restore did not preserve the user's .vimrc byte-for-byte" [[ ! -e "$XDG_CONFIG_HOME/selfishell/zsh/zshrc" ]] || fail "uninstall left managed configuration behind" grep -Fq 'user starship config' "$XDG_CONFIG_HOME/starship.toml" || fail "uninstall --restore did not restore the original starship.toml" - [[ -L "$XDG_CONFIG_HOME/vim/vimrc" && "$(readlink "$XDG_CONFIG_HOME/vim/vimrc")" == /nonexistent-target ]] || - fail "uninstall --restore did not restore the original dangling vimrc symlink" printf 'PASS: primary configuration lifecycle (clean install, idempotent reinstall, status, update, uninstall --restore)\n' } diff --git a/tests/managed_install_test.bash b/tests/managed_install_test.bash index 301c580..75afe20 100755 --- a/tests/managed_install_test.bash +++ b/tests/managed_install_test.bash @@ -77,7 +77,10 @@ test_install_copies_configuration_and_tracks_resources() { grep -Fqx '# >>> Selfishell zshenv >>>' "$HOME/.zshenv" || fail "Zshenv block start marker is missing" grep -Fqx 'skip_global_compinit=1' "$HOME/.zshenv" || fail "Zshenv block body is missing" assert_symlink_to "$XDG_CONFIG_HOME/selfishell/starship.toml" "$XDG_CONFIG_HOME/starship.toml" - assert_symlink_to "$XDG_CONFIG_HOME/selfishell/vim/vimrc" "$XDG_CONFIG_HOME/vim/vimrc" + [[ -f "$HOME/.vimrc" && ! -L "$HOME/.vimrc" ]] || fail "Vim startup file is not user-owned" + grep -Fqx '" >>> Selfishell vimrc >>>' "$HOME/.vimrc" || fail "Vim block start marker is missing" + [[ "$(sed -n '2p' "$XDG_STATE_HOME/selfishell/resources/user-vimrc.state")" == block ]] || + fail "Vim resource was not recorded as a managed block" cmp -s "$ROOT_DIR/config/shared/zsh/common.zsh" "$XDG_CONFIG_HOME/selfishell/zsh/common.zsh" || fail "Common Zsh configuration was not copied" cmp -s "$ROOT_DIR/config/shared/zsh/runtime.zsh" "$XDG_CONFIG_HOME/selfishell/zsh/runtime.zsh" || @@ -804,6 +807,53 @@ test_macos_lifecycle_never_touches_existing_zshenv() { fail "macOS lifecycle created a user-zshenv managed state for an existing file" } +test_vimrc_user_content_survives_lifecycle() { + local state_file="$XDG_STATE_HOME/selfishell/resources/user-vimrc.state" + + printf 'set background=dark\nset nocompatible\n' >"$HOME/.vimrc" + + run_selfishell install --profile minimal --skip-packages --yes >/dev/null + + [[ -f "$HOME/.vimrc" && ! -L "$HOME/.vimrc" ]] || fail "Vim startup file is not user-owned" + grep -Fqx '" >>> Selfishell vimrc >>>' "$HOME/.vimrc" || fail "Vim block start marker is missing" + grep -Fqx '" <<< Selfishell vimrc <<<' "$HOME/.vimrc" || fail "Vim block end marker is missing" + grep -Fq 'set background=dark' "$HOME/.vimrc" || fail "Original .vimrc content was not preserved" + [[ "$(grep -Fc '" >>> Selfishell vimrc >>>' "$HOME/.vimrc")" -eq 1 ]] || + fail "Vim block marker appears more than once" + [[ "$(sed -n '2p' "$state_file")" == block ]] || fail "Vim resource was not recorded as a managed block" + + run_selfishell install --profile minimal --skip-packages --yes >/dev/null + [[ "$(grep -Fc '" >>> Selfishell vimrc >>>' "$HOME/.vimrc")" -eq 1 ]] || + fail "Reinstall duplicated the Vim block marker" + + run_selfishell update --tools-only --skip-packages --yes >/dev/null + [[ "$(grep -Fc '" >>> Selfishell vimrc >>>' "$HOME/.vimrc")" -eq 1 ]] || + fail "Update duplicated the Vim block marker" + grep -Fq 'set background=dark' "$HOME/.vimrc" || fail "Original .vimrc content was lost across reinstall/update" + + run_selfishell uninstall --yes >/dev/null + ! grep -Fq '" >>> Selfishell vimrc >>>' "$HOME/.vimrc" || fail "Uninstall did not remove the Vim block" + assert_file_content $'set background=dark\nset nocompatible' "$HOME/.vimrc" +} + +test_unrelated_vimrc_symlink_is_rejected_without_changes() { + local status + + printf 'dotfiles vimrc\n' >"$TEST_ROOT/dotfiles-vimrc" + ln -s "$TEST_ROOT/dotfiles-vimrc" "$HOME/.vimrc" + + set +e + run_selfishell install --skip-packages --yes >/dev/null 2>&1 + status=$? + set -e + + [[ "$status" -eq 1 ]] || fail "Vimrc symlink should stop installation" + assert_symlink_to "$TEST_ROOT/dotfiles-vimrc" "$HOME/.vimrc" + assert_file_content 'dotfiles vimrc' "$TEST_ROOT/dotfiles-vimrc" + [[ ! -e "$XDG_CONFIG_HOME/selfishell" ]] || fail "Rejected symlink created configuration" + [[ ! -e "$XDG_STATE_HOME/selfishell" ]] || fail "Rejected symlink created state" +} + test_malformed_managed_file_state_variants_are_rejected_without_changes() { run_selfishell install --profile minimal --skip-packages --yes >/dev/null From ca75c01ede6d299f73bd977e496cd58f05bd1c8e Mon Sep 17 00:00:00 2001 From: jiminu Date: Tue, 25 Aug 2026 13:41:09 +0900 Subject: [PATCH 2/2] test: add Vim startup regression check in Ubuntu container E2E --- scripts/ubuntu-container-e2e.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/ubuntu-container-e2e.sh b/scripts/ubuntu-container-e2e.sh index d793a36..f59c7e3 100755 --- a/scripts/ubuntu-container-e2e.sh +++ b/scripts/ubuntu-container-e2e.sh @@ -44,6 +44,9 @@ SELFISHELL_RELEASE_ROOT="file://$RELEASE_ROOT" \ "$PREFIX/bin/selfishell" status >/dev/null "$PREFIX/bin/selfishell" doctor >/dev/null +vim --not-a-term -c 'if !&number || !&relativenumber | cquit 1 | endif' -c 'q'