diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac4e6315..4e8d7412 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,9 @@ defaults: run: shell: bash +env: + NU_LOG_LEVEL: DEBUG + jobs: tests: strategy: diff --git a/README.md b/README.md index d045d857..c0fe602c 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,14 @@ A collection of Nushell tools to manage `git` repositories. # Table of content -- [*what is `nu-git-manager`*](#bulb-what-is-nu-git-manager-toc) -- [*requirements*](#link-requirements-toc) -- [*installation*](#recycle-installation-toc) -- [*usage*](#gear-usage-toc) - - [*getting help*](#pray-getting-help-toc) -- [*some ideas of advanced (?) usage*](#exclamation-some-ideas-of-advanced--usage-toc) +- [nu-git-manager](#nu-git-manager) +- [Table of content](#table-of-content) + - [:bulb: what is `nu-git-manager` \[toc\]](#bulb-what-is-nu-git-manager-toc) + - [:link: requirements \[toc\]](#link-requirements-toc) + - [:recycle: installation \[toc\]](#recycle-installation-toc) + - [:gear: usage \[toc\]](#gear-usage-toc) + - [:pray: getting help \[toc\]](#pray-getting-help-toc) + - [:exclamation: some ideas of advanced (?) usage \[toc\]](#exclamation-some-ideas-of-advanced--usage-toc) ## :bulb: what is `nu-git-manager` [[toc](#table-of-content)] like [`ghq`](https://github.com/x-motemen/ghq), `nu-git-manager` aims at being a fully-featured @@ -26,9 +28,6 @@ it provides two main modules: - `gh` (optional) 2.29.0 (used by `sugar gh`) - with Pacman and `pacman -S community/github-cli` - with Nix and `nix run nixpkgs#gh` -- `find` 4.9.0 - - with Pacman and `pacman -S core/findutils` - - with Nix and `nix run nixpkgs#findutils` ## :recycle: installation [[toc](#table-of-content)] - install [Nupm] (**recommended**) by following the [Nupm instructions] diff --git a/nu-git-manager/fs/path.nu b/nu-git-manager/fs/path.nu index 48a8007e..051d67f2 100644 --- a/nu-git-manager/fs/path.nu +++ b/nu-git-manager/fs/path.nu @@ -1,4 +1,4 @@ # sanitize a Windows path export def "path sanitize" []: path -> path { - str replace --all '\' '/' + str replace --regex '^.:' '' | str replace --all '\' '/' } diff --git a/nu-git-manager/fs/store.nu b/nu-git-manager/fs/store.nu index 3ca15f10..3810475a 100644 --- a/nu-git-manager/fs/store.nu +++ b/nu-git-manager/fs/store.nu @@ -1,3 +1,4 @@ +use std log use path.nu "path sanitize" export def get-repo-store-path []: nothing -> path { @@ -6,17 +7,49 @@ export def get-repo-store-path []: nothing -> path { ) | path expand | path sanitize } +export def get-repo-store-cache-path []: nothing -> path { + $env.XDG_CACHE_HOME? + | default ($nu.home-path | path join ".cache") + | path join "nu-git-manager/cache.nuon" + | path expand + | path sanitize +} + +export def check-cache-file [cache_file: path]: nothing -> nothing { + if not ($cache_file | path exists) { + error make --unspanned { + msg: ( + $"(ansi red_bold)cache_not_found(ansi reset):\n" + + $"please run `(ansi default_dimmed)gm cache --update(ansi reset)` to create the cache" + ) + } + } +} + export def list-repos-in-store []: nothing -> list { if not (get-repo-store-path | path exists) { + log debug $"the store does not exist: `(get-repo-store-path)`" return [] } - if $nu.os-info.name == "windows" { - # FIXME: this is super slow on windows - glob **/*.git --not [**/*.venv **/node_modules/** **/target/** **/build/** */] - } else { - # FIXME: do not use external `find` command - ^find (get-repo-store-path) -name ".git" - | lines - } | each { path split | range 0..(-2) | path join } + # FIXME: glob does not work very well with Windows and absolute paths: the easy fix is to `cd` + # first and then perform the globbing + # related to https://github.com/nushell/nushell/issues/7125 + cd (get-repo-store-path) + let heads: list = glob "**/HEAD" --not [ + **/.git/**/refs/remotes/**/HEAD, + **/.git/modules/**/HEAD, + **/logs/HEAD + ] + # NOTE: we need to keep the trailing `/` here to avoid telling that `foo.bar` is a duplicate of + # `foo`, because `foo/` is not contained in `foo.bar/` + let repos = $heads | each { path sanitize } | str replace --regex '(.git/)?HEAD$' '' + + let sorted = $repos | sort + let pairs = $sorted | range 1.. | zip ($sorted | range ..(-2)) + $pairs + | filter {|it| not ($it.0 | str starts-with $it.1)} + | each { get 0 } + | prepend $sorted.0 + | str trim --right --char "/" } diff --git a/nu-git-manager/mod.nu b/nu-git-manager/mod.nu index 38a396e6..51f2e0a5 100644 --- a/nu-git-manager/mod.nu +++ b/nu-git-manager/mod.nu @@ -1,6 +1,8 @@ use std log -use fs/store.nu [get-repo-store-path, list-repos-in-store] +use fs/store.nu [ + check-cache-file, get-repo-store-path, get-repo-store-cache-path, list-repos-in-store +] use git/url.nu [parse-git-url, get-fetch-push-urls] def "nu-complete git-protocols" []: nothing -> table { @@ -70,6 +72,15 @@ export def "gm clone" [ git -C $local_path remote set-url $remote $urls.fetch git -C $local_path remote set-url $remote --push $urls.push + + let cache_file = get-repo-store-cache-path + check-cache-file $cache_file + + print --no-newline "updating cache... " + open $cache_file | append $local_path | uniq | sort | save --force $cache_file + print "done" + + null } # list all the local repositories in your local store @@ -86,12 +97,15 @@ export def "gm clone" [ export def "gm list" [ --full-path # show the full path instead of only the "owner + group + repo" name ]: nothing -> list { + let cache_file = get-repo-store-cache-path + check-cache-file $cache_file + + let repos = open $cache_file if $full_path { - list-repos-in-store + $repos } else { - let root = get-repo-store-path - list-repos-in-store | each { - str replace $root '' | str trim --left --char (char path_sep) + $repos | each { + str replace (get-repo-store-path) '' | str trim --left --char "/" } } } @@ -111,6 +125,38 @@ export def "gm root" []: nothing -> path { get-repo-store-path } +# get the path to the cache of the local store of repositories managed by `nu-git-manager` +# +# `nu-git-manager` will look for a cache in the following places, in order: +# - `$env.XDG_CACHE_HOME | path join "nu-git-manager/cache.nuon" +# - `~/.cache/nu-git-manager/cache.nuon` +# +# # Example +# a contrived example, assuming you are in `~` +# > XDG_CACHE_HOME=foo gm root +# ~/foo/nu-git-manager/cache.nuon +# +# update the cache of repositories +# > gm cache --update +export def "gm cache" [ + --update # will dump the content of the store to the cache of `nu-git-manager` +]: nothing -> path { + let cache_file = get-repo-store-cache-path + + if $update { + rm --recursive --force $cache_file + mkdir ($cache_file | path dirname) + + print --no-newline "updating cache... " + list-repos-in-store | save --force $cache_file + print "done" + + return + } + + get-repo-store-cache-path +} + # remove one of the repositories from your local store # # # Examples @@ -127,9 +173,9 @@ export def "gm remove" [ --fuzzy # remove after fuzzy-finding the repo(s) to clean ]: nothing -> nothing { let root = get-repo-store-path - let choices = list-repos-in-store + let choices = gm list | each { - str replace $root '' | str trim --left --char (char path_sep) + str replace $root '' | str trim --left --char "/" } | find $pattern @@ -165,9 +211,19 @@ export def "gm remove" [ let prompt = $"are you (ansi defu)sure(ansi reset) you want to (ansi red_bold)remove(ansi reset) (ansi yellow)($repo_to_remove)(ansi reset)? " match (["no", "yes"] | input list $prompt) { - "no" => { log info $"user chose to (ansi green_bold)keep(ansi reset) (ansi yellow)($repo_to_remove)(ansi reset)" }, + "no" => { + log info $"user chose to (ansi green_bold)keep(ansi reset) (ansi yellow)($repo_to_remove)(ansi reset)" + return + }, "yes" => { rm --recursive --force --verbose ($root | path join $repo_to_remove) }, } + let cache_file = get-repo-store-cache-path + check-cache-file $cache_file + + print --no-newline "updating cache... " + open $cache_file | where $it != ($root | path join $repo_to_remove) | save --force $cache_file + print "done" + null } diff --git a/tests/mod.nu b/tests/mod.nu index 251ca8fd..32a87e5f 100644 --- a/tests/mod.nu +++ b/tests/mod.nu @@ -1,7 +1,9 @@ use std assert use ../nu-git-manager/git/url.nu [parse-git-url, get-fetch-push-urls] -use ../nu-git-manager/fs/store.nu get-repo-store-path +use ../nu-git-manager/fs/store.nu [ + get-repo-store-path, get-repo-store-cache-path, list-repos-in-store +] use ../nu-git-manager/fs/path.nu "path sanitize" export def path-sanitization [] { @@ -88,3 +90,65 @@ export def get-store-root [] { assert equal $actual ($case.expected | path expand | path sanitize) } } + +export def get-repo-cache [] { + let cases = [ + [env, expected]; + + [{XDG_CACHE_HOME: null}, "~/.cache/nu-git-manager/cache.nuon"], + [{XDG_CACHE_HOME: "~/xdg"}, "~/xdg/nu-git-manager/cache.nuon"], + ] + + for case in $cases { + let actual = with-env $case.env { get-repo-store-cache-path } + assert equal $actual ($case.expected | path expand | path sanitize) + } +} + +export def list-all-repos-in-store [] { + # NOTE: `$BASE` is a constant, hence the capitalized name, but `path sanitize` is not a + # parse-time command + let BASE = ( + $nu.temp-path | path join "nu-git-manager/tests/list-all-repos-in-store" | path sanitize + ) + + if ($BASE | path exists) { + rm --recursive --verbose --force $BASE + } + mkdir $BASE + + let store = [ + [is_bare, in_store, path]; + + [false, true, "a/normal/"], + [true, true, "a/bare/"], + [false, true, "b/c/d/normal/"], + [true, true, "b/c/d/bare/"], + [false, false, "a/normal/b/nested/"], + [false, false, "a/normal/.git/modules/foo/"], + [false, true, "a/normal.but.more.complex/"], + ] + + for repo in $store { + if $repo.is_bare { + git init --bare ($BASE | path join $repo.path) + } else { + git init ($BASE | path join $repo.path) + } + } + + # NOTE: remove the path to BASE so that the test output is easy to read + let actual = with-env {GIT_REPOS_HOME: $BASE} { list-repos-in-store } | each { + str replace $BASE '' | str trim --left --char "/" + } + let expected = $store | where in_store | get path | each { + # NOTE: `list-repos-in-store` does not add `/` at the end of the paths + str trim --right --char "/" + } + + # NOTE: need to sort the result to make sure the order of the `git init` does not influence the + # results of the test + assert equal ($actual | sort) ($expected | sort) + + rm --recursive --verbose --force $BASE +}