diff --git a/.github/workflows/test-fish.yml b/.github/workflows/test-fish.yml new file mode 100644 index 00000000..bfe000b0 --- /dev/null +++ b/.github/workflows/test-fish.yml @@ -0,0 +1,36 @@ +name: Fish tests + +on: + push: + paths: + - fish/** + - scripts/git_main_branch + - .github/workflows/test-fish.yml + pull_request: + paths: + - fish/** + - scripts/git_main_branch + - .github/workflows/test-fish.yml + +jobs: + git-wrapper: + name: Git wrapper (${{ matrix.os }}) + strategy: + matrix: + os: [macos-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + + - name: Install Fish on macOS + if: runner.os == 'macOS' + run: brew install fish + + - name: Install Fish on Linux + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y fish + + - name: Test Git wrapper + run: fish --no-config fish/tests/git_wrapper_test.fish diff --git a/docs/adr/023-ci-lint-et-installation.md b/docs/adr/023-ci-lint-et-installation.md index 25840750..46a4d2eb 100644 --- a/docs/adr/023-ci-lint-et-installation.md +++ b/docs/adr/023-ci-lint-et-installation.md @@ -13,11 +13,13 @@ au pire moment. ## Décision -Deux workflows GitHub Actions. `lint.yml` vérifie la syntaxe Fish +Trois workflows GitHub Actions. `lint.yml` vérifie la syntaxe Fish (`fish --no-execute`), le formatage (`fish_indent`), le Lua Neovim (`luacheck`) -et les scripts shell (ShellCheck). `test.yml` exécute l'installation complète -sur un runner macOS, apps payantes exclues (`SKIP_PAID_APPS`) et cibles Docker -ignorées en l'absence de daemon (`DOCKER_OR_SKIP`). +et les scripts shell (ShellCheck). `test-fish.yml` exécute les tests Fish sur +macOS et Linux uniquement lorsque les fichiers qu'ils couvrent changent. +`test.yml` exécute l'installation complète sur un runner macOS, apps payantes +exclues (`SKIP_PAID_APPS`) et cibles Docker ignorées en l'absence de daemon +(`DOCKER_OR_SKIP`). ## Conséquences diff --git a/fish/conf.d/git-wrapper.fish b/fish/conf.d/git-wrapper.fish new file mode 100644 index 00000000..159fb52d --- /dev/null +++ b/fish/conf.d/git-wrapper.fish @@ -0,0 +1,151 @@ +set -l git_main_branch_script (path resolve (path dirname (status filename))/../../scripts/git_main_branch) + +function git --inherit-variable git_main_branch_script + set -l command_name + set -l command_arguments + set -l git_context + set -l git_prefix + set -l argument_index 1 + while test $argument_index -le (count $argv) + set -l argument $argv[$argument_index] + switch "$argument" + case -C -c --git-dir --work-tree --namespace --config-env + if test $argument_index -eq (count $argv) + break + end + set -l option_value $argv[(math $argument_index + 1)] + set -a git_context "$argument" "$option_value" + set -a git_prefix "$argument" "$option_value" + set argument_index (math $argument_index + 2) + case -p --paginate -P --no-pager --no-replace-objects --no-lazy-fetch --no-optional-locks --no-advice --bare + set -a git_prefix "$argument" + if contains -- "$argument" --no-replace-objects --no-lazy-fetch --no-optional-locks --bare + set -a git_context "$argument" + end + set argument_index (math $argument_index + 1) + case '--git-dir=*' '--work-tree=*' '--namespace=*' '--config-env=*' + set -a git_context "$argument" + set -a git_prefix "$argument" + set argument_index (math $argument_index + 1) + case '-*' + break + case '*' + set command_name "$argument" + if test $argument_index -lt (count $argv) + set command_arguments $argv[(math $argument_index + 1)..-1] + end + break + end + end + + set -l tracked_tips + set -l sync_requested + if contains -- "$command_name" fetch pull; and not contains -- --dry-run $command_arguments + if not test -x "$git_main_branch_script" + echo "git: branch cleanup disabled: $git_main_branch_script is unavailable" >&2 + command git $argv + return $status + end + + set -l primary_branch (command "$git_main_branch_script" --strict $git_context) + if test $status -ne 0; or test -z "$primary_branch" + echo "git: branch cleanup disabled: primary branch lookup failed" >&2 + command git $argv + return $status + end + + set sync_requested 1 + for branch_ref in (command git $git_context for-each-ref --format='%(refname)' refs/heads) + set -l branch (string replace refs/heads/ '' "$branch_ref") + if contains -- "$branch" "$primary_branch" development staging + continue + end + + set -l upstream_ref (command git $git_context for-each-ref --format='%(upstream)' "$branch_ref") + if test -z "$upstream_ref" + continue + end + + set -l upstream_oid (command git $git_context rev-parse --verify --quiet "$upstream_ref^{commit}") + if test $status -ne 0 + echo "git: keeping $branch: upstream tip unavailable before $command_name" >&2 + continue + end + + set -a tracked_tips (string join \t -- "$branch" "$upstream_oid") + end + end + + if test -n "$sync_requested" + command git $git_prefix $command_name --prune $command_arguments + else + command git $argv + end + set -l command_status $status + + if test -n "$sync_requested"; and test $command_status -eq 0 + for tracked_tip in $tracked_tips + set -l fields (string split \t -- "$tracked_tip") + set -l branch $fields[1] + set -l upstream_oid $fields[2] + set -l upstream_state (command git $git_context for-each-ref --format='%(upstream:track)' "refs/heads/$branch") + if test "$upstream_state" != '[gone]' + continue + end + + set -l local_oid (command git $git_context rev-parse --verify --quiet "refs/heads/$branch^{commit}") + if test $status -ne 0 + echo "git: keeping $branch: local tip unavailable after $command_name" >&2 + continue + end + if test "$local_oid" != "$upstream_oid" + echo "git: keeping $branch: local tip differs from its last upstream" >&2 + continue + end + + set -l worktree_list (command git $git_context worktree list --porcelain) + if test $status -ne 0 + echo "git: keeping $branch: worktree list failed after $command_name" >&2 + continue + end + set -l branch_worktrees (string match -- "branch refs/heads/$branch" $worktree_list) + if test (count $branch_worktrees) -gt 1 + echo "git: keeping $branch: branch is checked out in multiple worktrees" >&2 + continue + end + + set -l wt (command git $git_context for-each-ref --format='%(worktreepath)' "refs/heads/$branch" | string collect) + set -l worktree_lookup_status $pipestatus[1] + if test $worktree_lookup_status -ne 0 + echo "git: keeping $branch: worktree lookup failed after $command_name" >&2 + continue + end + if test -n "$wt" + set -l index_state (command git -C "$wt" ls-files -v) + if test $status -ne 0 + echo "git: keeping $branch: worktree index lookup failed after $command_name" >&2 + continue + end + if string match -rq -- '^[a-zS] ' $index_state + echo "git: keeping $branch: worktree index hides file changes" >&2 + continue + end + set -l worktree_state (command git -C "$wt" status --porcelain --untracked-files=all --ignored --ignore-submodules=none) + if test $status -ne 0 + echo "git: keeping $branch: worktree status failed after $command_name" >&2 + continue + end + if test (count $worktree_state) -gt 0 + echo "git: keeping $branch: worktree contains local files or changes" >&2 + continue + end + command git $git_context worktree remove "$wt" + or continue + end + command git $git_context update-ref -d "refs/heads/$branch" "$upstream_oid" + or echo "git: keeping $branch: local tip changed during cleanup" >&2 + end + end + + return $command_status +end diff --git a/fish/config.fish b/fish/config.fish index 6efb3bfd..3d04412d 100644 --- a/fish/config.fish +++ b/fish/config.fish @@ -36,20 +36,3 @@ if not string match -q -- $PNPM_HOME $PATH set -gx PATH "$PNPM_HOME" $PATH end # pnpm end - -# purge local branches whose upstream was deleted ([gone]) after every fetch/pull -# only branches that HAD an upstream now gone are pruned — never-pushed work is left untouched -function git - command git $argv - if test (count $argv) -gt 0; and contains -- $argv[1] fetch pull - # --prune marks deleted upstreams as [gone] in `git branch -vv` - command git fetch --prune 2>/dev/null - for branch in (command git branch -vv | grep ': gone]' | string replace -r '^[ *+]+(\S+).*' '$1' | grep -vE '^(main|development|staging)$') - set wt (command git worktree list 2>/dev/null | grep "\[$branch\]" | awk '{print $1}') - if test -n "$wt" - command git worktree remove --force $wt 2>/dev/null - end - command git branch -D $branch 2>/dev/null - end - end -end diff --git a/fish/tests/git_wrapper_test.fish b/fish/tests/git_wrapper_test.fish new file mode 100644 index 00000000..f714b383 --- /dev/null +++ b/fish/tests/git_wrapper_test.fish @@ -0,0 +1,316 @@ +set -g project_root (path resolve (path dirname (status filename))/../..) +set -g test_root (mktemp -d) + +function fail --argument-names message + echo "not ok - $message" >&2 + exit 1 +end + +test -n "$test_root"; and test -d "$test_root" +or fail "mktemp did not create the test directory" + +function cleanup --on-event fish_exit + command rm -rf -- "$test_root" + or echo "not ok - failed to remove $test_root" >&2 +end + +function must + command $argv + or begin + set -l rendered_command (string join ' ' -- $argv) + fail "command failed: $rendered_command" + end +end + +set -gx _ZO_DATA_DIR "$test_root/zoxide" +must mkdir "$test_root/zoxide" + +function create_repository --argument-names name + set -g remote_path "$test_root/$name.git" + set -g repository_path "$test_root/$name" + must git init --bare --quiet "$remote_path" + must git init --quiet --initial-branch=main "$repository_path" + must git -C "$repository_path" config user.email test@example.com + must git -C "$repository_path" config user.name Test + must git -C "$repository_path" remote add origin "$remote_path" + echo base >"$repository_path/file" + must git -C "$repository_path" add file + must git -C "$repository_path" commit --quiet --message base + must git -C "$repository_path" push --quiet --set-upstream origin main +end + +function create_tracked_branch --argument-names branch + must git -C "$repository_path" switch --quiet --create "$branch" + echo "$branch" >"$repository_path/file" + must git -C "$repository_path" commit --quiet --all --message "$branch" + must git -C "$repository_path" push --quiet --set-upstream origin "$branch" + must git -C "$repository_path" switch --quiet main +end + +source "$project_root/fish/conf.d/git-wrapper.fish" +or fail "could not source fish/conf.d/git-wrapper.fish" + +create_repository divergent-branch +create_tracked_branch feature +must git -C "$repository_path" switch --quiet feature +echo local >>"$repository_path/file" +must git -C "$repository_path" commit --quiet --all --message local +must git -C "$repository_path" switch --quiet main +must git --git-dir="$remote_path" update-ref -d refs/heads/feature + +cd "$repository_path" +or fail "could not enter $repository_path" +git fetch +or fail "fetch failed in divergent-branch" + +command git show-ref --verify --quiet refs/heads/feature +or fail "fetch preserves a branch with local commits" + +echo "ok - fetch preserves a branch with local commits" + +create_repository clean-worktree +create_tracked_branch feature +set -l worktree_path "$test_root/clean-feature" +must git -C "$repository_path" worktree add --quiet "$worktree_path" feature +must git --git-dir="$remote_path" update-ref -d refs/heads/feature + +cd "$repository_path" +or fail "could not enter $repository_path" +git fetch +or fail "fetch failed in clean-worktree" + +not test -e "$worktree_path" +or fail "fetch removes a clean worktree without local commits" +not command git show-ref --verify --quiet refs/heads/feature +or fail "fetch removes a branch without local commits" + +echo "ok - fetch removes a clean worktree and branch without local commits" + +create_repository dirty-worktree +create_tracked_branch feature +set worktree_path "$test_root/dirty-feature" +must git -C "$repository_path" worktree add --quiet "$worktree_path" feature +echo dirty >>"$worktree_path/file" +echo untracked >"$worktree_path/untracked" +must git --git-dir="$remote_path" update-ref -d refs/heads/feature + +cd "$repository_path" +or fail "could not enter $repository_path" +set -l fetch_output (git fetch 2>&1) +or fail "fetch failed in dirty-worktree" + +test -e "$worktree_path" +or fail "fetch preserves a dirty worktree" +command git show-ref --verify --quiet refs/heads/feature +or fail "fetch preserves the branch of a dirty worktree" +string match --quiet -- '*dirty*' (command git -C "$worktree_path" diff) +or fail "fetch preserves uncommitted worktree changes" +test -e "$worktree_path/untracked" +or fail "fetch preserves untracked worktree files" +string match --quiet -- '*worktree contains local files or changes*' $fetch_output +or fail "fetch reports why a dirty worktree is preserved" + +echo "ok - fetch preserves a dirty worktree and branch" + +create_repository ignored-worktree +create_tracked_branch feature +must git -C "$repository_path" switch --quiet feature +echo ignored >"$repository_path/.gitignore" +must git -C "$repository_path" add .gitignore +must git -C "$repository_path" commit --quiet --message ignore +must git -C "$repository_path" push --quiet +must git -C "$repository_path" switch --quiet main +set worktree_path "$test_root/ignored-feature" +must git -C "$repository_path" worktree add --quiet "$worktree_path" feature +echo local >"$worktree_path/ignored" +must git --git-dir="$remote_path" update-ref -d refs/heads/feature + +cd "$repository_path" +or fail "could not enter $repository_path" +set fetch_output (git fetch 2>&1) +or fail "fetch failed in ignored-worktree" + +test -e "$worktree_path/ignored" +or fail "fetch preserves ignored worktree files" +command git show-ref --verify --quiet refs/heads/feature +or fail "fetch preserves the branch of a worktree with ignored files" + +echo "ok - fetch preserves a worktree with ignored files" + +create_repository assume-unchanged-worktree +create_tracked_branch feature +set worktree_path "$test_root/assume-unchanged-feature" +must git -C "$repository_path" worktree add --quiet "$worktree_path" feature +must git -C "$worktree_path" update-index --assume-unchanged file +echo local >>"$worktree_path/file" +must git --git-dir="$remote_path" update-ref -d refs/heads/feature + +cd "$repository_path" +or fail "could not enter $repository_path" +set fetch_output (git fetch 2>&1) +or fail "fetch failed in assume-unchanged-worktree" + +test -e "$worktree_path/file" +or fail "fetch preserves assume-unchanged worktree changes" +command git show-ref --verify --quiet refs/heads/feature +or fail "fetch preserves the branch of an assume-unchanged worktree" + +echo "ok - fetch preserves an assume-unchanged worktree" + +create_repository skip-worktree +create_tracked_branch feature +set worktree_path "$test_root/skip-worktree-feature" +must git -C "$repository_path" worktree add --quiet "$worktree_path" feature +must git -C "$worktree_path" update-index --skip-worktree file +must git --git-dir="$remote_path" update-ref -d refs/heads/feature + +cd "$repository_path" +or fail "could not enter $repository_path" +set fetch_output (git fetch 2>&1) +or fail "fetch failed in skip-worktree" + +test -e "$worktree_path/file" +or fail "fetch preserves a skip-worktree worktree" +command git show-ref --verify --quiet refs/heads/feature +or fail "fetch preserves the branch of a skip-worktree worktree" + +echo "ok - fetch preserves a skip-worktree worktree" + +create_repository multiple-worktrees +create_tracked_branch feature +set -l first_worktree_path "$test_root/first-feature" +set -l second_worktree_path "$test_root/second-feature" +must git -C "$repository_path" worktree add --quiet "$first_worktree_path" feature +must git -C "$repository_path" worktree add --quiet --force "$second_worktree_path" feature +must git --git-dir="$remote_path" update-ref -d refs/heads/feature + +cd "$repository_path" +or fail "could not enter $repository_path" +set fetch_output (git fetch 2>&1) +or fail "fetch failed in multiple-worktrees" + +test -e "$first_worktree_path/file" +or fail "fetch preserves the first of multiple worktrees" +test -e "$second_worktree_path/file" +or fail "fetch preserves the second of multiple worktrees" +command git show-ref --verify --quiet refs/heads/feature +or fail "fetch preserves a branch checked out in multiple worktrees" + +echo "ok - fetch preserves a branch checked out in multiple worktrees" + +create_repository failed-fetch +create_tracked_branch feature +must git --git-dir="$remote_path" update-ref -d refs/heads/feature + +cd "$repository_path" +or fail "could not enter $repository_path" +set -l failed_fetch_output (git fetch missing 2>&1) +set -l fetch_status $status + +test $fetch_status -ne 0 +or fail "fetch preserves the original failure status, got $fetch_status" +string match --quiet -- '*does not appear to be a git repository*' $failed_fetch_output +or fail "failed fetch keeps its error visible" +command git show-ref --verify --quiet refs/heads/feature +or fail "failed fetch does not clean branches" + +echo "ok - failed fetch preserves its status and branches" + +create_repository dry-run +create_tracked_branch feature +must git --git-dir="$remote_path" update-ref -d refs/heads/feature + +cd "$repository_path" +or fail "could not enter $repository_path" +git fetch --dry-run +or fail "dry-run fetch failed" + +command git show-ref --verify --quiet refs/heads/feature +or fail "dry-run fetch does not clean branches" +command git show-ref --verify --quiet refs/remotes/origin/feature +or fail "dry-run fetch does not prune remote-tracking branches" + +echo "ok - dry-run fetch leaves branches unchanged" + +create_repository unavailable-upstream +create_tracked_branch feature +must git --git-dir="$remote_path" update-ref -d refs/heads/feature +must git -C "$repository_path" update-ref -d refs/remotes/origin/feature + +cd "$repository_path" +or fail "could not enter $repository_path" +set fetch_output (git fetch 2>&1) +or fail "fetch failed in unavailable-upstream" + +command git show-ref --verify --quiet refs/heads/feature +or fail "fetch preserves a branch when its previous upstream tip is unavailable" +string match --quiet -- '*upstream tip unavailable*' $fetch_output +or fail "fetch reports an unavailable previous upstream tip" + +echo "ok - unavailable upstream tip preserves the branch" + +create_repository single-fetch +create_tracked_branch feature +must git -C "$repository_path" remote add backup "$remote_path" +must git --git-dir="$remote_path" update-ref -d refs/heads/feature +must git -C "$repository_path" remote set-url origin "$test_root/missing.git" + +cd "$repository_path" +or fail "could not enter $repository_path" +set fetch_output (git fetch backup 2>&1) +set fetch_status $status + +test $fetch_status -eq 0 +or fail "fetching another remote succeeds, got $fetch_status" +command git show-ref --verify --quiet refs/heads/feature +or fail "fetching another remote preserves branches tracking origin" +not string match --quiet -- '*does not appear to be a git repository*' $fetch_output +or fail "fetch does not contact an unrequested remote" + +echo "ok - fetch contacts only the requested remote" + +create_repository pull +create_tracked_branch feature +must git --git-dir="$remote_path" update-ref -d refs/heads/feature + +cd "$repository_path" +or fail "could not enter $repository_path" +git pull --ff-only +or fail "pull failed" + +not command git show-ref --verify --quiet refs/heads/feature +or fail "pull removes a branch without local commits" + +echo "ok - pull removes a branch without local commits" + +create_repository global-options +create_tracked_branch feature +must git --git-dir="$remote_path" update-ref -d refs/heads/feature + +cd "$test_root" +or fail "could not enter $test_root" +git --no-pager -C "$repository_path" fetch +or fail "fetch with global options failed" + +not command git -C "$repository_path" show-ref --verify --quiet refs/heads/feature +or fail "fetch with global options removes a branch without local commits" + +echo "ok - fetch with global options removes a branch without local commits" + +for primary_branch in master trunk + create_repository "primary-$primary_branch" + must git -C "$repository_path" branch --move "$primary_branch" + must git -C "$repository_path" push --quiet --set-upstream origin "$primary_branch" + must git -C "$repository_path" switch --quiet --create parking + must git --git-dir="$remote_path" update-ref -d "refs/heads/$primary_branch" + + cd "$repository_path" + or fail "could not enter $repository_path" + git fetch + or fail "fetch failed for primary branch $primary_branch" + + command git show-ref --verify --quiet "refs/heads/$primary_branch" + or fail "fetch preserves primary branch $primary_branch" +end + +echo "ok - fetch preserves dynamically detected primary branches" diff --git a/scripts/git_main_branch b/scripts/git_main_branch index c8cc85be..715a44c7 100755 --- a/scripts/git_main_branch +++ b/scripts/git_main_branch @@ -1,16 +1,30 @@ #!/usr/bin/env bash +strict= +if [[ ${1-} == "--strict" ]]; then + strict=1 + shift +fi + # Check if we're in a git repository -if ! command git rev-parse --git-dir &>/dev/null; then +if ! command git "$@" rev-parse --git-dir &>/dev/null; then + if [[ -n $strict ]]; then + exit 1 + fi echo "main" exit 0 fi # Try common branch names that exist locally for branch in main master trunk; do - if git show-ref -q --verify "refs/heads/$branch" 2>/dev/null; then + if git "$@" show-ref -q --verify "refs/heads/$branch" 2>/dev/null; then echo "$branch" exit 0 + else + show_ref_status=$? + if [[ $show_ref_status -ne 1 && -n $strict ]]; then + exit "$show_ref_status" + fi fi done