diff --git a/README.md b/README.md index 2500c1e..1d222af 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ Git checkouts are updated with Git. `z.install` replaces the core while preserving installed mods. Use `z.install.mod ` to update a single mod. +`z.install.mod.list` lists available and installed mods: green for installed, +orange for different contents, and red for not installed. It compares against +`Z_INSTALL_BRANCH` (default: `main`); local edits also appear orange. + ### Uninstall `z.uninstall` diff --git a/lib/install/mod/list/analysis.zsh b/lib/install/mod/list/analysis.zsh new file mode 100644 index 0000000..89aaf8e --- /dev/null +++ b/lib/install/mod/list/analysis.zsh @@ -0,0 +1,32 @@ +# compare an installed mod with the selected distribution +# +# $source: distribution mod directory +# $installed: installed mod directory +# REPLY: installed|update-available|not-installed +# return: 0 if compared, otherwise 1 +# +# example: +# z.install.mod.list._status source=/tmp/source/git installed=~/.z/mod/git +z.install.mod.list._status() { + z.arg.named source "$@" && local source_dir=$REPLY + z.arg.named installed "$@" && local installed_dir=$REPLY + + if z.dir.not.exists "$installed_dir"; then + z.return not-installed + return + fi + + if z.dir.not.exists "$source_dir"; then + z.return installed + return + fi + + diff -qr -- "$source_dir" "$installed_dir" >/dev/null + local result_code=$? + + case $result_code in + 0) z.return installed ;; + 1) z.return update-available ;; + *) return 1 ;; + esac +} diff --git a/lib/install/mod/list/process.zsh b/lib/install/mod/list/process.zsh new file mode 100644 index 0000000..f2416be --- /dev/null +++ b/lib/install/mod/list/process.zsh @@ -0,0 +1,27 @@ +# fetch the selected distribution's mod directory into a work directory +# +# $dir: existing work directory +# $branch?: distribution branch (default: main) +# REPLY: extracted mod directory +# return: 0|1 +# +# example: +# z.install.mod.list._fetch dir=/tmp/mod-list branch=main +z.install.mod.list._fetch() { + z.arg.named dir "$@" && local dir=$REPLY + z.arg.named branch "$@" default=main && local branch=$REPLY + + local archive="$dir/source.tar.gz" + local url="https://github.com/milkeclair/z/archive/refs/heads/$branch.tar.gz" + + curl -fLsS "$url" -o "$archive" || return 1 + tar -xzf "$archive" -C "$dir" || return 1 + + local roots=("$dir"/*(N/)) + if ! z.int.is.eq ${#roots} 1 || z.dir.not.exists "$roots[1]/mod"; then + z.io.error "mod directory not found in the distribution" + return 1 + fi + + z.return "$roots[1]/mod" +} diff --git a/lib/install/mod/process.zsh b/lib/install/mod/process.zsh new file mode 100644 index 0000000..01cf851 --- /dev/null +++ b/lib/install/mod/process.zsh @@ -0,0 +1,40 @@ +# list available and installed mods from the selected distribution branch +# green: installed, orange: different contents, red: not installed +# local edits are also shown as update-available +# +# REPLY: null +# return: 0|1 +# +# example: +# z.install.mod.list +z.install.mod.list() { + local work_dir=$(mktemp -d) + z.dir.exists "$work_dir" || return 1 + + { + z.install.mod.list._fetch dir="$work_dir" branch="${Z_INSTALL_BRANCH:-main}" || return 1 + local source_dir=$REPLY + + local names=("$source_dir"/*(N/:t) "$z_root"/mod/*(N/:t)) + z.arr.unique "${names[@]}" + z.arr.sort "${(@)REPLY}" + local mod_names=(${(@)REPLY}) + + for mod_name in "${mod_names[@]}"; do + z.install.mod.list._status source="$source_dir/$mod_name" installed="$z_root/mod/$mod_name" || return 1 + local mod_state=$REPLY + local color=green + + case $mod_state in + update-available) color=orange ;; + not-installed) color=red ;; + esac + + z.io color="$color" "$mod_name [$mod_state]" + done + } always { + z.dir.remove path="$work_dir" || return 1 + } + + z.return +} diff --git a/lib/str/process.zsh b/lib/str/process.zsh index 8725288..4153794 100644 --- a/lib/str/process.zsh +++ b/lib/str/process.zsh @@ -1,6 +1,7 @@ typeset -gA z_color_palette=( [red]=$'\033[31m' [green]=$'\033[32m' + [orange]=$'\033[38;5;208m' [yellow]=$'\033[33m' [blue]=$'\033[34m' [magenta]=$'\033[35m' diff --git a/main.zsh b/main.zsh index eff82b7..3a1a4db 100644 --- a/main.zsh +++ b/main.zsh @@ -9,6 +9,7 @@ local -A z_modules=( [file]="operator:process" [fn]="analysis:operator:process" [hash]="analysis:operator:process" + [install]="process" [int]="operator" [io]="process" [mode]="process" @@ -42,6 +43,7 @@ local z_module_depends_order=( str t update + install ) local -A loaded_files=() diff --git a/test/lib/install/mod/list/analysis_test.zsh b/test/lib/install/mod/list/analysis_test.zsh new file mode 100644 index 0000000..71576b8 --- /dev/null +++ b/test/lib/install/mod/list/analysis_test.zsh @@ -0,0 +1,93 @@ +source "$z_main" + +z.t.describe "z.install.mod.list._status"; { + z.t.context "未インストールの場合"; { + z.t.it "not-installedを返す"; { + local fixture=$(mktemp -d /tmp/z_mod_status.XXXXXX) + z.dir.make path="$fixture/source" + + z.install.mod.list._status source="$fixture/source" installed="$fixture/installed" + + z.t.expect.reply not-installed + + z.dir.remove path="$fixture" + } + } + + z.t.context "配布元と内容が一致する場合"; { + z.t.it "隠しfileとsubdirectoryも比較してinstalledを返す"; { + local fixture=$(mktemp -d /tmp/z_mod_status.XXXXXX) + z.dir.make path="$fixture/source/nested" + z.file.write path="$fixture/source/nested/value" content=same + z.file.write path="$fixture/source/.settings" content=same + cp -R "$fixture/source" "$fixture/installed" + + z.install.mod.list._status source="$fixture/source" installed="$fixture/installed" + + z.t.expect.reply installed + + z.dir.remove path="$fixture" + } + } + + z.t.context "配布元とfileの内容が異なる場合"; { + z.t.it "update-availableを返す"; { + local fixture=$(mktemp -d /tmp/z_mod_status.XXXXXX) + z.dir.make path="$fixture/source" + z.dir.make path="$fixture/installed" + z.file.write path="$fixture/source/value" content=new + z.file.write path="$fixture/installed/value" content=old + + z.install.mod.list._status source="$fixture/source" installed="$fixture/installed" + + z.t.expect.reply update-available + + z.dir.remove path="$fixture" + } + } + + z.t.context "fileの追加・削除がある場合"; { + for side in source installed; do + z.t.it "$side側だけにfileがあるとupdate-availableを返す"; { + local fixture=$(mktemp -d /tmp/z_mod_status.XXXXXX) + z.dir.make path="$fixture/source" + z.dir.make path="$fixture/installed" + z.file.write path="$fixture/$side/only" content=changed + + z.install.mod.list._status source="$fixture/source" installed="$fixture/installed" + + z.t.expect.reply update-available + + z.dir.remove path="$fixture" + } + done + } + + z.t.context "導入済みのmodが配布元にない場合"; { + z.t.it "installedを返す"; { + local fixture=$(mktemp -d /tmp/z_mod_status.XXXXXX) + z.dir.make path="$fixture/installed" + + z.install.mod.list._status source="$fixture/missing" installed="$fixture/installed" + + z.t.expect.reply installed + + z.dir.remove path="$fixture" + } + } + + z.t.context "比較中に読み取りerrorが起きた場合"; { + z.t.it "更新ありと誤認せず失敗する"; { + local fixture=$(mktemp -d /tmp/z_mod_status.XXXXXX) + z.dir.make path="$fixture/source" + z.dir.make path="$fixture/installed" + z.t.mock name=diff behavior="return 2" + + z.install.mod.list._status source="$fixture/source" installed="$fixture/installed" 2>/dev/null + + z.t.expect.status.is.false + + z.dir.remove path="$fixture" + } + } +} diff --git a/test/lib/install/mod/list/process_test.zsh b/test/lib/install/mod/list/process_test.zsh new file mode 100644 index 0000000..d3cc35a --- /dev/null +++ b/test/lib/install/mod/list/process_test.zsh @@ -0,0 +1,94 @@ +source "$z_main" + +z.t.describe "z.install.mod.list._fetch"; { + z.t.context "配布archiveを取得できる場合"; { + z.t.it "指定branchを一度取得してmod配布元を返す"; { + local fixture=$(mktemp -d /tmp/z_mod_fetch.XXXXXX) + z.dir.make path="$fixture/input/z-topic/mod/example" + z.dir.make path="$fixture/work" + z.file.write path="$fixture/input/z-topic/mod/example/value" content=remote + tar -czf "$fixture/archive.tar.gz" -C "$fixture/input" z-topic + + z.t.mock name=curl behavior="cp \"$fixture/archive.tar.gz\" \"$fixture/work/source.tar.gz\"" + + z.install.mod.list._fetch dir="$fixture/work" branch=topic/change + local result_code=$? + local source_dir=$REPLY + + z.t.mock.result name=curl + local call=$REPLY + + z.t.expect "$result_code" 0 + z.t.expect "$source_dir" "$fixture/work/z-topic/mod" + z.t.expect "$call" "-fLsS https://github.com/milkeclair/z/archive/refs/heads/topic/change.tar.gz -o $fixture/work/source.tar.gz" + + z.file.read path="$source_dir/example/value" + z.t.expect.reply remote + + z.dir.remove path="$fixture" + } + } + + z.t.context "取得に失敗した場合"; { + z.t.it "展開を行わず失敗する"; { + local fixture=$(mktemp -d /tmp/z_mod_fetch.XXXXXX) + local -a extracted=() + z.t.mock name=curl behavior="return 22" + z.t.mock name=tar behavior='extracted+=(called); return 0' + + z.install.mod.list._fetch dir="$fixture" 2>/dev/null + local result_code=$? + + z.t.expect "$result_code" 1 skip_unmock=true + z.t.expect "$extracted" "" + + z.dir.remove path="$fixture" + } + } + + z.t.context "archiveが壊れている場合"; { + z.t.it "失敗する"; { + local fixture=$(mktemp -d /tmp/z_mod_fetch.XXXXXX) + z.t.mock name=curl behavior="z.file.write path=\"$fixture/source.tar.gz\" content=invalid" + + z.install.mod.list._fetch dir="$fixture" 2>/dev/null + + z.t.expect.status.is.false + + z.dir.remove path="$fixture" + } + } + + z.t.context "mod directoryが含まれない場合"; { + z.t.it "一覧を空として扱わず失敗する"; { + local fixture=$(mktemp -d /tmp/z_mod_fetch.XXXXXX) + z.dir.make path="$fixture/input/z-main/lib" + z.dir.make path="$fixture/work" + tar -czf "$fixture/archive.tar.gz" -C "$fixture/input" z-main + z.t.mock name=curl behavior="cp \"$fixture/archive.tar.gz\" \"$fixture/work/source.tar.gz\"" + + z.install.mod.list._fetch dir="$fixture/work" 2>/dev/null + + z.t.expect.status.is.false + + z.dir.remove path="$fixture" + } + } + + z.t.context "配布元directoryが複数ある場合"; { + z.t.it "任意の配布元を選ばず失敗する"; { + local fixture=$(mktemp -d /tmp/z_mod_fetch.XXXXXX) + z.dir.make path="$fixture/input/one/mod" + z.dir.make path="$fixture/input/two/mod" + z.dir.make path="$fixture/work" + tar -czf "$fixture/archive.tar.gz" -C "$fixture/input" one two + z.t.mock name=curl behavior="cp \"$fixture/archive.tar.gz\" \"$fixture/work/source.tar.gz\"" + + z.install.mod.list._fetch dir="$fixture/work" 2>/dev/null + + z.t.expect.status.is.false + + z.dir.remove path="$fixture" + } + } +} diff --git a/test/lib/install/mod/process_test.zsh b/test/lib/install/mod/process_test.zsh new file mode 100644 index 0000000..e4cd900 --- /dev/null +++ b/test/lib/install/mod/process_test.zsh @@ -0,0 +1,152 @@ +source "$z_main" + +z.t.describe "z.install.mod.list"; { + z.t.context "複数の導入状態が混在する場合"; { + z.t.it "名前順に重複なく表示して各状態を緑・オレンジ・赤にする"; { + local fixture=$(mktemp -d "/tmp/z mod list.XXXXXX") + local z_root="$fixture/installed" + local Z_INSTALL_BRANCH=topic/change + local work_dir="$fixture/work" + local fetch_calls=0 + z.dir.make path="$fixture/source/alpha" + z.dir.make path="$fixture/source/beta" + z.dir.make path="$fixture/source/gamma" + z.dir.make path="$z_root/mod/alpha" + z.dir.make path="$z_root/mod/beta" + z.dir.make path="$z_root/mod/local-only" + z.file.write path="$fixture/source/alpha/value" content=new + z.file.write path="$z_root/mod/alpha/value" content=old + z.file.write path="$fixture/source/beta/value" content=same + z.file.write path="$z_root/mod/beta/value" content=same + z.file.write path="$fixture/source/gamma/mod.zsh" content="touch \"$fixture/executed\"" + + z.t.mock name=mktemp behavior="z.dir.make path=\"$work_dir\"; z.io \"$work_dir\"" + z.t.mock name="z.install.mod.list._fetch" behavior="((fetch_calls++)); z.return \"$fixture/source\"" + + z.install.mod.list > "$fixture/output" + local result_code=$? + local result_reply=$REPLY + local output=$(< "$fixture/output") + + z.t.mock.result name="z.install.mod.list._fetch" + local fetch_args=$REPLY + + z.t.expect "$result_code" 0 + z.t.expect "$result_reply" "" + z.t.expect "$fetch_calls" 1 + z.t.expect "$fetch_args" "dir=$work_dir branch=topic/change" + z.t.expect "$output" $'\033[38;5;208malpha [update-available]\033[0m\n\033[32mbeta [installed]\033[0m\n\033[31mgamma [not-installed]\033[0m\n\033[32mlocal-only [installed]\033[0m' + + z.dir.not.exists "$work_dir" + z.t.expect.status.is.true + + z.file.not.exists "$fixture/executed" + z.t.expect.status.is.true + + z.file.read path="$z_root/mod/alpha/value" + z.t.expect.reply old + + z.dir.remove path="$fixture" + } + } + + z.t.context "導入済みmodがない場合"; { + z.t.it "公開modを赤色で表示する"; { + local fixture=$(mktemp -d /tmp/z_mod_list.XXXXXX) + local z_root="$fixture/installed" + z.dir.make path="$fixture/source/example" + z.t.mock name="z.install.mod.list._fetch" behavior="z.return \"$fixture/source\"" + + z.install.mod.list > "$fixture/output" + local result_code=$? + local output=$(< "$fixture/output") + + z.t.expect "$result_code" 0 + z.t.expect "$output" $'\033[31mexample [not-installed]\033[0m' + + z.dir.remove path="$fixture" + } + } + + z.t.context "公開modも導入済みmodもない場合"; { + z.t.it "出力せず成功する"; { + local fixture=$(mktemp -d /tmp/z_mod_list.XXXXXX) + local z_root="$fixture/installed" + z.dir.make path="$fixture/source" + z.t.mock name="z.install.mod.list._fetch" behavior="z.return \"$fixture/source\"" + + z.install.mod.list > "$fixture/output" + local result_code=$? + + z.t.expect "$result_code" 0 + [[ ! -s "$fixture/output" ]] + z.t.expect.status.is.true + + z.dir.remove path="$fixture" + } + } + + z.t.context "取得に失敗した場合"; { + z.t.it "誤った一覧を出さず作業directoryを片付ける"; { + local fixture=$(mktemp -d /tmp/z_mod_list.XXXXXX) + local z_root="$fixture/installed" + local work_dir="$fixture/work" + z.t.mock name=mktemp behavior="z.dir.make path=\"$work_dir\"; z.io \"$work_dir\"" + z.t.mock name="z.install.mod.list._fetch" behavior="z.file.write path=\"$work_dir/partial\" content=partial; return 1" + + z.install.mod.list > "$fixture/output" 2>/dev/null + local result_code=$? + + z.t.expect "$result_code" 1 + [[ ! -s "$fixture/output" ]] + z.t.expect.status.is.true + + z.dir.not.exists "$work_dir" + z.t.expect.status.is.true + + z.dir.remove path="$fixture" + } + } + + z.t.context "modの比較に失敗した場合"; { + z.t.it "状態を表示せず作業directoryを片付ける"; { + local fixture=$(mktemp -d /tmp/z_mod_list.XXXXXX) + local z_root="$fixture/installed" + local work_dir="$fixture/work" + z.dir.make path="$fixture/source/example" + z.dir.make path="$z_root/mod/example" + z.t.mock name=mktemp behavior="z.dir.make path=\"$work_dir\"; z.io \"$work_dir\"" + z.t.mock name="z.install.mod.list._fetch" behavior="z.return \"$fixture/source\"" + z.t.mock name=diff behavior="return 2" + + z.install.mod.list > "$fixture/output" 2>/dev/null + local result_code=$? + + z.t.expect "$result_code" 1 + [[ ! -s "$fixture/output" ]] + z.t.expect.status.is.true + + z.dir.not.exists "$work_dir" + z.t.expect.status.is.true + + z.dir.remove path="$fixture" + } + } + + z.t.context "作業directoryを確保できない場合"; { + z.t.it "取得を開始せず失敗する"; { + local fixture=$(mktemp -d /tmp/z_mod_list.XXXXXX) + local -a fetched=() + z.t.mock name=mktemp behavior="return 1" + z.t.mock name="z.install.mod.list._fetch" behavior='fetched+=(called); return 0' + + z.install.mod.list > "$fixture/output" 2>/dev/null + local result_code=$? + + z.t.expect "$result_code" 1 skip_unmock=true + z.t.expect "$fetched" "" + + z.dir.remove path="$fixture" + } + } +} diff --git a/test/lib/str/process_test.zsh b/test/lib/str/process_test.zsh index 57d9303..64a9d5c 100644 --- a/test/lib/str/process_test.zsh +++ b/test/lib/str/process_test.zsh @@ -9,6 +9,9 @@ z.t.describe "z.str.color"; { z.str.color "green" z.t.expect.reply $'\033[32m' + z.str.color "orange" + z.t.expect.reply $'\033[38;5;208m' + z.str.color "yellow" z.t.expect.reply $'\033[33m'