diff --git a/README.md b/README.md index 7bdc4cd..1e186ae 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Hyprland desktop setup on Fedora Linux, styled with Catppuccin Mocha. Built for │ │ ├── reload.sh # Reload compositor, bar, and notifications │ │ └── wallpaper.sh # Per-workspace wallpaper listener │ ├── kitty/ -│ │ └── kitty.conf # GPU terminal with 144Hz input tuning +│ │ └── kitty.conf # GPU terminal with configured redraw delays │ ├── rofi/ │ │ ├── config.rasi # Application launcher │ │ ├── scripts/ @@ -109,11 +109,11 @@ https://github.com/user-attachments/assets/e41b7de6-2717-4371-a81f-c4a124757f12 |---|---| | [Hyprland](https://github.com/hyprwm/Hyprland) | Wayland tiling compositor | | [Waybar](https://github.com/Alexays/Waybar) | Floating status bar with workspace indicators | -| [awww](https://codeberg.org/LGFae/awww) | Wallpaper daemon running 144 FPS bezier transitions | +| [awww](https://codeberg.org/LGFae/awww) | Wallpaper daemon with transitions configured at 144 FPS | | [SwayNC](https://github.com/ErikReider/SwayNotificationCenter) | Notification center and quick settings | | [hyprlock](https://github.com/hyprwm/hyprlock) | Screen locker with screenshot blur | | [hypridle](https://github.com/hyprwm/hypridle) | Idle listener and sleep controller | -| [Kitty](https://github.com/kovidgoyal/kitty) | Terminal emulator running 6ms refresh and 1ms input latency | +| [Kitty](https://github.com/kovidgoyal/kitty) | Terminal emulator with 6ms repaint and 1ms input delay settings | | [Rofi](https://github.com/davatorium/rofi) | App launcher, power menu, and picker scripts | | [Fastfetch](https://github.com/fastfetch-cli/fastfetch) | System information summary | @@ -154,13 +154,19 @@ The main modifier key is `SUPER`. ## Installation & setup +The installer requires `python3` and **Git 2.30 or later** (`git config --fixed-value`). +The option is documented in [Git 2.30's configuration reference](https://github.com/git/git/blob/v2.30.0/Documentation/git-config.txt). +`delta` is needed only for the installed Git presentation settings, not to run +the installer. Symlinked `.bashrc` and `.gitconfig` files require manual integration; +do not replace those links to run this installer. + To link all configuration directories (`hypr`, `waybar`, `kitty`, `rofi`, `swaync`, `fastfetch`, `btop`, `starship.toml`) to `~/.config/`: ```bash ./scripts/install-symlinks.sh ``` -Existing conflicting configuration files or directories will be safely backed up automatically with a timestamp before creating the symlinks. +Existing conflicting files, directories, and symlinks are preserved in unique backup directories before replacement. ## System optimizations @@ -191,3 +197,21 @@ Clipboard history supports Enter to copy, Delete to delete the selected entry, and Ctrl+Delete to clear history after confirmation. Escape leaves the clipboard and history alone. Wallpaper selection displays relative paths and handles punctuation and duplicate filenames without regex matching. + + +### Portable Bash and Git settings + +The installer links `shell/dotfiles.sh` into `~/.bashrc.d/` and includes +`git/developer.gitconfig` from local Git configuration. Shell tools are initialized +only in interactive shells, and missing optional tools are skipped. Local Git +identity, credentials, signing, and differing presentation overrides are preserved. + +Migration removes only the exact recognized legacy Bash block and matching Git +presentation settings. It backs up changed startup files and replaces `.bashrc` +atomically, preserving its mode. Symlink conflicts are saved as `BACKUP/original`. +Restore the printed backups to undo installation. + +The Bash loader check matches literal text without executing `.bashrc`. If that +text exists only in an unreachable block, manually source the fragment from an +active part of `.bashrc`. Locally edited legacy blocks may also need manual +integration to avoid duplicate initialization. diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md new file mode 100644 index 0000000..5c6e3ee --- /dev/null +++ b/docs/performance-baseline.md @@ -0,0 +1,87 @@ +# Workstation performance baseline + +## TL;DR + +Preserve the current visuals and target balanced responsiveness. Configuration is +not a benchmark. The measurements below were captured in a restricted agent +sandbox, not a complete Hyprland session; no desktop performance gain is claimed. + +## Recorded evidence + +| Check | Observation | Limit | +|---|---|---| +| OS | Fedora 43 KDE edition | Read from host os-release | +| Memory/storage | About 30 GiB RAM, 8 GiB zram, Btrfs on NVMe | Snapshot, not a pressure test | +| Shell with current startup | 20 samples; median 39.32 ms; range 38.53–41.25 ms | Non-login interactive Bash, no PTY, output discarded | +| Shell without Fastfetch | 20 samples; median 33.95 ms; range 32.84–35.54 ms | Temporary rc file; original startup untouched | +| Fastfetch difference | 5.37 ms between medians | Sandbox observation only; retain current behavior | +| Codec package | mesa-va-drivers-freeworld installed; mesa-va-drivers absent | Package inventory does not prove application decoding | +| Power configuration | `/etc/tuned/active_profile`: balanced; EPP: balance_performance | Live TuneD activation could not be queried | +| Monitor/compositor | Unverified | Hyprland socket access denied | +| Boot/failed services | Unverified | System and user bus access denied | +| Desktop idle CPU/power | Unverified | Process visibility excludes the live desktop | + +The historical samples above collected each variant as a separate batch. Cache +warm-up or changing host load can bias that difference. The repeat procedure +below interleaves variants and alternates first position within each pair; the +historical numbers have not been remeasured with this procedure. + +## Repeat shell startup measurement + +Run from your normal host terminal. This measures startup without terminal +rendering, so also compare perceived opening time in Kitty. The temporary variant +intercepts the direct Fastfetch startup invocation; it never edits dotfiles. + +```python +import pathlib, statistics, subprocess, tempfile, time + +original = pathlib.Path.home().joinpath('.bashrc').read_text() +with tempfile.TemporaryDirectory() as directory: + variants = ('current', 'without_fastfetch') + samples = {variant: [] for variant in variants} + configs = {} + for variant in variants: + rc = pathlib.Path(directory) / variant + # Intercept only direct fastfetch calls in this disposable child shell. + # The tracked fragment and existing rc both use direct calls. + rc.write_text(('fastfetch() { :; }\n' if variant == 'without_fastfetch' else '') + original) + configs[variant] = rc + for round_number in range(20): + order = variants if round_number % 2 == 0 else variants[::-1] + for variant in order: + rc = configs[variant] + start = time.perf_counter() + subprocess.run(['bash', '--noprofile', '--rcfile', str(rc), '-ic', 'exit'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + samples[variant].append((time.perf_counter() - start) * 1000) + for variant, values in samples.items(): + assert len(values) == 20 + print(variant, 'median/range ms:', statistics.median(values), min(values), max(values)) +``` + +This temporary function does not intercept an explicit `command fastfetch` or +absolute executable path; inspect customized startup files before using it. +Retain Fastfetch unless repeated host measurements or your preference justify +moving it to an on-demand command. + +## Complete the host baseline + +- Capture `systemd-analyze`, `systemd-analyze critical-chain`, and failed system/user + units across three comparable cold boots. Report medians and ranges, not a single + longest service: services can start in parallel. +- Run `hyprctl monitors`, `hyprctl configerrors`, `tuned-adm active`, and + `tuned-adm verify`. Confirm actual resolution/refresh and power-profile activation. +- Count `wallpaper.sh` listener processes after login and after ten `SUPER+A` + refreshes. There should be one listener. +- With the same apps open and no builds/downloads, capture three sixty-second + idle samples using `pidstat -u -r 1 60` if installed. Record compositor, bar, + wallpaper, CPU activity, and available hardware power readings separately. +- Inspect `vainfo` and the browser's media diagnostics during the same video test. + Confirm the codec, rendering/decoding device, CPU use, and dropped frames. + +## Change policy + +Keep blur, transparency, animations, VRR, Kitty delays, and wallpaper transition +rate unchanged in this series. Test one proposed tuning change at a time with the +same workload. Adopt it only if improvement is repeatable beyond baseline variation, +and record any visual, latency, or idle-power tradeoff plus rollback. diff --git a/git/developer.gitconfig b/git/developer.gitconfig new file mode 100644 index 0000000..529e6d8 --- /dev/null +++ b/git/developer.gitconfig @@ -0,0 +1,11 @@ +# Portable diff presentation; identity, credentials, and signing remain local. +[core] + pager = delta +[interactive] + diffFilter = delta --color-only +[delta] + navigate = true + light = false + side-by-side = false + line-numbers = true + syntax-theme = "Catppuccin Mocha" diff --git a/scripts/install-symlinks.sh b/scripts/install-symlinks.sh index 96adf6d..0c4e729 100755 --- a/scripts/install-symlinks.sh +++ b/scripts/install-symlinks.sh @@ -1,30 +1,33 @@ #!/usr/bin/env bash +# Link tracked configs and portable fragments, preserving local settings in backups. set -euo pipefail +for tool in python3 git; do + command -v "$tool" >/dev/null || { echo "Missing required tool: $tool" >&2; exit 1; } +done +DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" -DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CONFIG_SOURCE="$DOTFILES_DIR/.config" -TARGET_DIR="$HOME/.config" - -echo "[+] Installing dotfiles symlinks into $TARGET_DIR..." -mkdir -p "$TARGET_DIR" - -for item in "$CONFIG_SOURCE"/*; do - base="$(basename "$item")" - target="$TARGET_DIR/$base" - - if [ -L "$target" ] && [ "$(readlink -f "$target")" = "$item" ]; then - echo " [=] $base is already linked." - continue +# Preserve conflicts, including dangling links, before installing one source. +link_item() { + local source=$1 target=$2 backup + mkdir -p "$(dirname "$target")" + if [[ -L $target && $(readlink -f "$target") == "$source" ]]; then + echo "Already linked: $target" + return fi - - if [ -e "$target" ]; then - backup="${target}.backup-$(date +%s)" - echo " [!] Backing up existing $base to $backup" - mv "$target" "$backup" + if [[ -e $target || -L $target ]]; then + backup=$(mktemp -d "${target}.backup-XXXXXX") + mv -- "$target" "$backup/original" + echo "Backup: $backup/original" fi + ln -s -- "$source" "$target" + echo "Linked: $target" +} - ln -s "$item" "$target" - echo " [+] Linked $base -> $item" +# Migrate before linking so unsupported shell ownership fails without changing configs. +python3 "$DOTFILES_DIR/scripts/migrate-shell.py" +for item in "$DOTFILES_DIR/.config/"*; do + link_item "$item" "$HOME/.config/$(basename "$item")" done - -echo "[✓] All dotfiles successfully linked!" +link_item "$DOTFILES_DIR/shell/dotfiles.sh" "$HOME/.bashrc.d/dotfiles.sh" +link_item "$DOTFILES_DIR/git/developer.gitconfig" "$HOME/.config/git/developer.gitconfig" +echo 'Configuration installed. Open a new interactive shell to use the fragment.' diff --git a/scripts/migrate-shell.py b/scripts/migrate-shell.py new file mode 100644 index 0000000..c9ec52b --- /dev/null +++ b/scripts/migrate-shell.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Migrate only the recognized inline Bash block, preserving other local content.""" +from pathlib import Path +import shutil +import subprocess +import os +import tempfile + +LEGACY = '''alias syu="sudo dnf upgrade" + +# Modern developer aliases +if command -v eza >/dev/null 2>&1; then + alias ls="eza --icons --group-directories-first" + alias ll="eza -la --icons --group-directories-first" + alias tree="eza --tree --icons" +fi + +if command -v bat >/dev/null 2>&1; then + alias cat="bat --paging=never" +fi + +if command -v duf >/dev/null 2>&1; then + alias df="duf" +fi + +# Zoxide smart jumping (replaces cd) +command -v zoxide >/dev/null 2>&1 && eval "$(zoxide init bash)" + +# FZF keybindings & fuzzy completion +command -v fzf >/dev/null 2>&1 && eval "$(fzf --bash)" + +eval "$(starship init bash)" +fastfetch +''' +DIRECTORY_LOADER = '''if [ -d ~/.bashrc.d ]; then + for rc in ~/.bashrc.d/*; do + if [ -f "$rc" ]; then + . "$rc" + fi + done +fi''' +HOOK = '''# Dotfiles portable developer configuration +if [ -r "$HOME/.bashrc.d/dotfiles.sh" ]; then + . "$HOME/.bashrc.d/dotfiles.sh" +fi +''' + + +def migrate(path: Path) -> None: + """Back up and migrate an exact known block; never remove edited local code. + + Args: + path: Local Bash startup file. + Raises: + ValueError: The startup file is a symlink requiring manual integration. + OSError: Backup or update fails. + """ + if path.is_symlink(): + raise ValueError('Symlinked .bashrc: integrate shell/dotfiles.sh manually') + original = path.read_text() if path.exists() else '' + updated = original.replace(LEGACY, '') + # shortcut: loader detection matches raw text; manually source the fragment when a loader is unreachable. + if DIRECTORY_LOADER not in updated and HOOK not in updated: + updated = updated.rstrip('\n') + '\n\n' + HOOK + if updated == original: + return + if path.exists(): + with tempfile.NamedTemporaryFile(prefix=path.name + '.backup-', dir=path.parent, delete=False) as backup: + shutil.copy2(path, backup.name) + print(f'Bash backup: {backup.name}') + mode = path.stat().st_mode & 0o7777 if path.exists() else 0o600 + temporary_name = None + try: + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', prefix=path.name + '.tmp-', + dir=path.parent, delete=False) as temporary: + temporary_name = temporary.name + temporary.write(updated) + temporary.flush() + os.fchmod(temporary.fileno(), mode) + os.fsync(temporary.fileno()) + os.replace(temporary_name, path) + finally: + if temporary_name is not None: + Path(temporary_name).unlink(missing_ok=True) + + +def migrate_git(path: Path, include: Path) -> None: + """Prepend a portable include and remove only matching legacy Git values. + + Local identity and differing overrides stay after the include and take priority. + Raises ValueError for symlinked files and OSError or CalledProcessError on failure. + """ + if path.is_symlink(): + raise ValueError('Symlinked .gitconfig: integrate git/developer.gitconfig manually') + original = path.read_text() if path.exists() else '' + settings = { + 'core.pager': 'delta', 'interactive.diffFilter': 'delta --color-only', + 'delta.navigate': 'true', 'delta.light': 'false', 'delta.side-by-side': 'false', + 'delta.line-numbers': 'true', 'delta.syntax-theme': 'Catppuccin Mocha', + } + with tempfile.TemporaryDirectory(dir=path.parent) as directory: + draft = Path(directory) / 'gitconfig' + draft.write_text(original) + for key, value in settings.items(): + result = subprocess.run(['git', 'config', '--file', str(draft), '--fixed-value', + '--unset-all', key, value], capture_output=True, text=True, check=False) + if result.returncode not in (0, 5): + raise RuntimeError(f'Cannot migrate Git setting {key}: {result.stderr}') + # Let Git quote the path instead of implementing its configuration grammar. + prefix = Path(directory) / 'include' + subprocess.run(['git', 'config', '--file', str(prefix), '--add', 'include.path', str(include)], check=True) + existing = subprocess.run(['git', 'config', '--file', str(draft), '--get-all', 'include.path'], + capture_output=True, text=True, check=False) + if existing.returncode not in (0, 1): + raise RuntimeError('Cannot read Git include settings') + updated = draft.read_text() + if str(include) not in existing.stdout.splitlines(): + updated = prefix.read_text() + updated + if updated == original: + return + if path.exists(): + with tempfile.NamedTemporaryFile(prefix=path.name + '.backup-', dir=path.parent, delete=False) as backup: + shutil.copy2(path, backup.name) + print(f'Git backup: {backup.name}') + draft.chmod(path.stat().st_mode & 0o777) + else: + draft.chmod(0o600) + draft.write_text(updated) + os.replace(draft, path) + + +if __name__ == '__main__': + home = Path.home() + migrate(home / '.bashrc') + migrate_git(home / '.gitconfig', home / '.config/git/developer.gitconfig') diff --git a/shell/dotfiles.sh b/shell/dotfiles.sh new file mode 100644 index 0000000..cdaabee --- /dev/null +++ b/shell/dotfiles.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Portable interactive developer aliases, navigation, completion, and prompt. +[[ $- == *i* ]] || return 0 +alias syu='sudo dnf upgrade' +if command -v eza >/dev/null 2>&1; then + alias ls='eza --icons --group-directories-first' + alias ll='eza -la --icons --group-directories-first' + alias tree='eza --tree --icons' +fi +command -v bat >/dev/null 2>&1 && alias cat='bat --paging=never' +command -v duf >/dev/null 2>&1 && alias df=duf +if command -v zoxide >/dev/null 2>&1; then eval "$(zoxide init bash)"; fi +if command -v fzf >/dev/null 2>&1; then eval "$(fzf --bash)"; fi +if command -v starship >/dev/null 2>&1; then eval "$(starship init bash)"; fi +if command -v fastfetch >/dev/null 2>&1; then fastfetch; fi diff --git a/tests/install.sh b/tests/install.sh new file mode 100644 index 0000000..7abc2e3 --- /dev/null +++ b/tests/install.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Verify installation and migration preserve files, links, and local Git identity. +set -euo pipefail +repo=$(cd "$(dirname "$0")/.." && pwd) +task_tmp=$(mktemp -d) +trap 'rm -rf "$task_tmp"' EXIT +export HOME="$task_tmp/home with spaces" GIT_CONFIG_GLOBAL="$task_tmp/home with spaces/.gitconfig" GIT_CONFIG_SYSTEM=/dev/null +mkdir -p "$HOME/.config" "$HOME/.bashrc.d" +ln -s "$task_tmp/missing" "$HOME/.config/kitty" +printf 'local bar\n' > "$HOME/.config/waybar" +printf 'local shell\n' > "$HOME/.bashrc.d/local.sh" +printf '[user]\n name = Local User\n' > "$HOME/.gitconfig" +cat > "$HOME/.bashrc" <<'RC' +# local setup +export CUSTOM_LOCAL=preserved +# Modern developer aliases +if command -v eza >/dev/null 2>&1; then + alias ls="eza --icons --group-directories-first" +fi +# user code that must not be swallowed by partial migration +export KEEP_ME=yes +RC +# A partial or locally edited block must be preserved rather than guessed at. +bash "$repo/scripts/install-symlinks.sh" >/dev/null +[[ -L $HOME/.config/kitty && -L $HOME/.config/waybar ]] +[[ $(git config --global user.name) == 'Local User' ]] +[[ $(git config --global --get-all include.path | wc -l) == 1 ]] +[[ $(cat "$HOME/.bashrc.d/local.sh") == 'local shell' ]] +[[ $(cat "$HOME/.bashrc") == *'export KEEP_ME=yes'* ]] +[[ $(find "$HOME/.config" -maxdepth 1 -name 'kitty.backup-*' | wc -l) == 1 ]] +[[ $(find "$HOME/.config" -maxdepth 1 -name 'waybar.backup-*' | wc -l) == 1 ]] +bash "$repo/scripts/install-symlinks.sh" >/dev/null +[[ $(git config --global --get-all include.path | wc -l) == 1 ]] +[[ $(find "$HOME/.config" -maxdepth 1 -name 'waybar.backup-*' | wc -l) == 1 ]] +# Portable fragments must not initialize interactive tools in a script. +bash -c 'source "$HOME/.bashrc.d/dotfiles.sh"; ! alias syu >/dev/null 2>&1' +# A checkout reached through a symlink must still install physical source paths. +ln -s "$repo" "$task_tmp/checkout" +bash "$task_tmp/checkout/scripts/install-symlinks.sh" >/dev/null +bash "$task_tmp/checkout/scripts/install-symlinks.sh" >/dev/null +[[ $(find "$HOME/.config" -maxdepth 1 -name 'kitty.backup-*' | wc -l) == 1 ]] +[[ $(find "$HOME/.config" -maxdepth 1 -name 'waybar.backup-*' | wc -l) == 1 ]] +# Missing prerequisites must be named before any local startup file is changed. +for missing in python3 git; do + isolated="$task_tmp/missing-$missing" + mkdir -p "$isolated/bin" "$isolated/home" + if [[ $missing == python3 ]]; then other=git; else other=python3; fi + ln -s "$(command -v "$other")" "$isolated/bin/$other" + printf 'local settings\n' > "$isolated/home/.bashrc" + if HOME="$isolated/home" PATH="$isolated/bin" /bin/bash "$repo/scripts/install-symlinks.sh" > "$isolated/output" 2>&1; then + echo "Installer accepted missing $missing" >&2 + exit 1 + fi + grep -q "Missing required tool: $missing" "$isolated/output" + [[ $(cat "$isolated/home/.bashrc") == 'local settings' ]] +done +printf 'Installer regression checks passed\n' diff --git a/tests/measurement.py b/tests/measurement.py new file mode 100644 index 0000000..14a7d8f --- /dev/null +++ b/tests/measurement.py @@ -0,0 +1,31 @@ +"""Check the documented benchmark's pairing without launching shell processes.""" +from contextlib import redirect_stdout +import io +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +class MeasurementTests(unittest.TestCase): + """Keep the executable measurement instructions aligned with their claims.""" + def test_variants_alternate_within_twenty_pairs(self) -> None: + """Alternate first position to avoid collecting one variant as a batch.""" + document = Path(__file__).resolve().parents[1] / 'docs/performance-baseline.md' + code = document.read_text().split('```python\n', 1)[1].split('```', 1)[0] + commands = [] + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + (home / '.bashrc').write_text('fastfetch\n') + def capture(args: list[str], **kwargs: object) -> subprocess.CompletedProcess: + """Capture each variant instead of starting an actual shell.""" + commands.append(Path(args[3]).name) + return subprocess.CompletedProcess(args, 0) + with patch.object(Path, 'home', return_value=home), patch.object(subprocess, 'run', side_effect=capture), redirect_stdout(io.StringIO()): + exec(compile(code, str(document), 'exec'), {}) + self.assertEqual(commands, ['current', 'without_fastfetch', 'without_fastfetch', 'current'] * 10) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/migration.py b/tests/migration.py new file mode 100644 index 0000000..7ce1123 --- /dev/null +++ b/tests/migration.py @@ -0,0 +1,72 @@ +"""Verify exact legacy migration and precedence of local Git overrides.""" +import importlib.util +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('migration', Path(__file__).resolve().parents[1] / 'scripts/migrate-shell.py') +migration = importlib.util.module_from_spec(spec) +spec.loader.exec_module(migration) + +class MigrationTests(unittest.TestCase): + """Protect local startup files and Git override precedence during migration.""" + def test_bash_replace_failure_preserves_original_and_cleans_temporary(self) -> None: + """A failed replacement must preserve startup content and its mode.""" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / '.bashrc' + original = 'export LOCAL=yes\n' + migration.LEGACY + path.write_text(original) + path.chmod(0o640) + with patch.object(migration.os, 'replace', side_effect=OSError('simulated replacement failure')): + with self.assertRaises(OSError): + migration.migrate(path) + self.assertEqual(path.read_text(), original) + self.assertEqual(path.stat().st_mode & 0o7777, 0o640) + backups = list(Path(directory).glob('.bashrc.backup-*')) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_text(), original) + self.assertEqual(list(Path(directory).glob('.bashrc.tmp-*')), []) + + def test_bash_replacement_preserves_mode(self) -> None: + """Atomic migration retains the original permissions.""" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / '.bashrc' + path.write_text(migration.LEGACY) + path.chmod(0o640) + migration.migrate(path) + self.assertEqual(path.stat().st_mode & 0o7777, 0o640) + self.assertEqual(list(Path(directory).glob('.bashrc.tmp-*')), []) + + def test_exact_bash_block_migrates_once(self) -> None: + """Remove the recognized block once and preserve unrelated settings.""" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / '.bashrc' + path.write_text('export LOCAL=yes\n' + migration.LEGACY) + migration.migrate(path) + migration.migrate(path) + self.assertIn('export LOCAL=yes', path.read_text()) + self.assertNotIn('starship init', path.read_text()) + self.assertEqual(path.read_text().count(migration.HOOK), 1) + self.assertEqual(len(list(Path(directory).glob('.bashrc.backup-*'))), 1) + + def test_git_local_override_wins_and_identity_survives(self) -> None: + """Portable Git defaults must not override local identity or preferences.""" + with tempfile.TemporaryDirectory(prefix='git home ') as directory: + path = Path(directory) / '.gitconfig' + fragment = Path(directory) / 'developer.gitconfig' + fragment.write_text('[core]\n pager=delta\n[delta]\n light=false\n') + path.write_text('[user]\n name=Local User\n[core]\n pager=less\n[delta]\n light=false\n') + migration.migrate_git(path, fragment) + migration.migrate_git(path, fragment) + def value(key: str) -> str: + """Read one effective test-config value with includes enabled.""" + return subprocess.check_output(['git', 'config', '--file', str(path), '--includes', '--get', key], text=True).strip() + self.assertEqual(value('core.pager'), 'less') + self.assertEqual(value('user.name'), 'Local User') + self.assertEqual(value('delta.light'), 'false') + self.assertEqual(len(list(Path(directory).glob('.gitconfig.backup-*'))), 1) + +if __name__ == '__main__': + unittest.main()