├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── dependabot-validate.yml │ ├── release.yml │ └── rust.yml ├── .gitignore ├── .gitmodules ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── clippy.toml ├── doc ├── example_config │ ├── projects │ │ └── default │ │ │ ├── docker │ │ │ ├── fw │ │ │ └── pybuilder │ ├── settings.toml │ └── tags │ │ ├── default │ │ ├── brogo │ │ ├── fkbr │ │ ├── git │ │ ├── js │ │ ├── python │ │ └── rust │ │ └── github │ │ └── brocode │ │ └── brocode ├── installation.md └── usage.md ├── logo ├── fw_cmyk.eps ├── fw_rgb.eps ├── fw_rgb.png └── fw_rgb.svg ├── man ├── Cargo.lock ├── Cargo.toml └── src │ └── main.rs ├── rustfmt.toml └── src ├── app └── mod.rs ├── config ├── metadata_from_repository.rs ├── mod.rs ├── path.rs ├── project.rs └── settings.rs ├── errors └── mod.rs ├── git └── mod.rs ├── intellij └── mod.rs ├── main.rs ├── project └── mod.rs ├── projectile └── mod.rs ├── setup └── mod.rs ├── shell ├── mod.rs ├── setup.bash ├── setup.fish ├── setup.zsh ├── workon-fzf.bash ├── workon-fzf.fish ├── workon-fzf.zsh ├── workon-sk.bash ├── workon-sk.fish ├── workon-sk.zsh ├── workon.bash ├── workon.fish └── workon.zsh ├── spawn └── mod.rs ├── sync └── mod.rs ├── tag └── mod.rs ├── util └── mod.rs ├── workon └── mod.rs └── ws ├── github └── mod.rs └── mod.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = tab 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.{yaml,yml}] 11 | indent_size = 2 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | all-dependencies-cargo: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "monthly" 15 | groups: 16 | all-dependencies-actions: 17 | patterns: 18 | - "*" 19 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-validate.yml: -------------------------------------------------------------------------------- 1 | name: dependabot validate 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - ".github/dependabot.yml" 7 | - ".github/workflows/dependabot-validate.yml" 8 | jobs: 9 | validate: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: marocchino/validate-dependabot@v3 14 | id: validate 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "v*" 5 | name: Release 6 | jobs: 7 | build: 8 | name: Release (github.com) 9 | runs-on: ubuntu-latest 10 | env: 11 | BINARY_NAME: fw 12 | CARGO_TERM_COLOR: always 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: docker://messense/rust-musl-cross:x86_64-musl 16 | with: 17 | args: cargo build --release 18 | - uses: docker://messense/rust-musl-cross:x86_64-musl 19 | with: 20 | args: musl-strip target/x86_64-unknown-linux-musl/release/fw 21 | - run: cp ./target/x86_64-unknown-linux-musl/release/fw fw 22 | - run: sha512sum fw > fw.sha512sum 23 | - run: cargo run >> fw.1 24 | working-directory: ./man 25 | - id: create_release 26 | uses: softprops/action-gh-release@v2 27 | with: 28 | tag_name: ${{ github.ref }} 29 | files: | 30 | fw 31 | man/fw.1 32 | fw.sha512sum 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | on: [push, pull_request] 3 | 4 | env: 5 | CARGO_TERM_COLOR: always 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Cache folders 17 | uses: actions/cache@v4 18 | with: 19 | path: | 20 | ~/.cargo/registry 21 | ~/.cargo/git 22 | target 23 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 24 | 25 | - name: Check formatting, run clippy and test 26 | run: cargo fmt --all -- --check && cargo clippy -- -D warnings && cargo test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bk 2 | *.iml 3 | 4 | ### Jet Brains ### 5 | .idea 6 | 7 | ### Rust ### 8 | /target/ 9 | /man/target 10 | 11 | # These are backup files generated by rustfmt 12 | **/*.rs.bk 13 | 14 | ### Vim ### 15 | # Swap 16 | [._]*.s[a-v][a-z] 17 | [._]*.sw[a-p] 18 | [._]s[a-rt-v][a-z] 19 | [._]ss[a-gi-z] 20 | [._]sw[a-p] 21 | 22 | # Session 23 | Session.vim 24 | 25 | # Temporary 26 | .netrwhist 27 | # Auto-generated tag files 28 | /tags 29 | # Persistent undo 30 | [._]*.un~ 31 | 32 | 33 | ### VisualStudioCode ### 34 | .vscode/ 35 | 36 | ## generated man file 37 | man/fw.1 38 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "rust-musl-builder"] 2 | path = rust-musl-builder 3 | url = https://github.com/emk/rust-musl-builder.git 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fw" 3 | version = "2.21.0" 4 | authors = ["brocode "] 5 | description = "faster workspace management" 6 | license = "WTFPL" 7 | categories = ["command-line-utilities"] 8 | repository = "https://github.com/brocode/fw" 9 | readme = "README.md" 10 | keywords = ["workspace", "productivity", "cli", "automation", "developer-tools" ] 11 | edition = "2024" 12 | include = ["src/**/*", "LICENSE", "README.md"] 13 | 14 | [dependencies] 15 | walkdir = "2" 16 | dirs = "6" 17 | toml = "0.8" 18 | serde_json = "1.0.140" 19 | serde = {version = "1", features = ["derive"] } 20 | git2 = "0.20" 21 | maplit = "1.0" 22 | rayon = "1" 23 | regex = "1" 24 | rand = "0.9" 25 | crossbeam = "0" 26 | indicatif = "0" 27 | openssl-probe = "0.1" 28 | reqwest = { version = "0", features = ["json", "blocking"] } 29 | tokio = { version = "1", features = ["full"] } 30 | openssl = { version = "0.10", features = ["vendored"] } 31 | 32 | [dependencies.clap] 33 | version = "4" 34 | features = ["cargo"] 35 | 36 | [dependencies.yansi] 37 | version = "1" 38 | features = ["detect-env"] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2004 Sam Hocevar 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](./logo/fw_rgb.png) 2 | 3 | # fw 4 | 5 | [![](https://img.shields.io/crates/v/fw.svg)](https://crates.io/crates/fw) 6 | 7 | [![](https://asciinema.org/a/222856.png)](https://asciinema.org/a/222856) 8 | 9 | ## Why fw? 10 | 11 | With `fw` you have a configuration describing your workspace. It takes 12 | care of cloning projects and can run commands across your entire 13 | workspace. You can start working on any project quickly, even if it\'s 14 | not in your flat structured workspace (better than `CDPATH`!). It also 15 | \"sets up\" your environment when you start working on a project 16 | (compile stuff, run `make`, activate `virtualenv` or `nvm`, fire up 17 | `sbt` shell, etc.) 18 | 19 | [*Here\'s*]{.spurious-link target="doc/example_config"} an example 20 | configuration that should be easy to grasp. 21 | 22 | The default configuration location is located under your system\'s 23 | config directory as described 24 | [here](https://docs.rs/dirs/3.0.2/dirs/fn.config_dir.html). That is : 25 | 26 | - Linux: `~/.config/fw` 27 | - MacOS: `$HOME/Library/Application Support/fw` 28 | - Windows: `{FOLDERID_RoamingAppData}\fw` 29 | 30 | The location and can be overridden by setting `FW_CONFIG_DIR`. 31 | 32 | Per default projects are cloned into 33 | `${settings.workspace}/${project.name}` but you can override that by 34 | setting an `override_path` attribute as seen in the example 35 | configuration. 36 | 37 | ## What this is, and isn\'t 38 | 39 | `fw` is a tool I wrote to do my bidding. It might not work for you if 40 | your workflow differs a lot from mine or might require adjustments. Here 41 | are the assumptions: 42 | 43 | - only git repositories 44 | - only ssh clone (easily resolveable by putting more work in the git2 45 | bindings usage) 46 | - `ssh-agent` based authentication 47 | 48 | ### If you can live with all of the above, you get: 49 | 50 | - workspace persistence (I can `rm -rf` my entire workspace and have 51 | it back in a few minutes) 52 | - ZERO overhead project switching with the `workon` function (need to 53 | activate `nvm`? Run `sbt`? Set LCD brightness to 100%? `fw` will do 54 | all that for you) 55 | - zsh completions on the project names for `workon` 56 | - generate projectile configuration for all your project (no need to 57 | `projectile-add-known-project` every time you clone some shit, it 58 | will just work) 59 | 60 | 61 | ## [Installation](doc/installation.md) 62 | 63 | ## [Usage](doc/usage.md) 64 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | too-many-arguments-threshold = 12 2 | -------------------------------------------------------------------------------- /doc/example_config/projects/default/docker: -------------------------------------------------------------------------------- 1 | # -*- mode: Conf; -*- 2 | git = 'git@github.com:docker/docker.git' 3 | override_path = '/home/brocode/go/src/github.com/docker/docker' 4 | 5 | # Example: 6 | # git = 'git@github.com:brocode/fw.git' 7 | # after_clone = 'echo BROCODE!!' 8 | # after_workon = 'echo workon fw' 9 | # override_path = '/some/fancy/path/to/fw' 10 | # bare = false 11 | # tags = [ 12 | # 'brocode', 13 | # 'rust', 14 | # ] 15 | # [[additional_remotes]] 16 | # name = 'upstream' 17 | # git = 'git@...' 18 | -------------------------------------------------------------------------------- /doc/example_config/projects/default/fw: -------------------------------------------------------------------------------- 1 | # -*- mode: Conf; -*- 2 | git = 'git@github.com:brocode/fw.git' 3 | after_clone = 'cargo build' 4 | tags = [ 5 | 'brocode', 6 | 'git', 7 | 'rust', 8 | ] 9 | 10 | # Example: 11 | # git = 'git@github.com:brocode/fw.git' 12 | # after_clone = 'echo BROCODE!!' 13 | # after_workon = 'echo workon fw' 14 | # override_path = '/some/fancy/path/to/fw' 15 | # bare = false 16 | # tags = [ 17 | # 'brocode', 18 | # 'rust', 19 | # ] 20 | # [[additional_remotes]] 21 | # name = 'upstream' 22 | # git = 'git@...' 23 | -------------------------------------------------------------------------------- /doc/example_config/projects/default/pybuilder: -------------------------------------------------------------------------------- 1 | # -*- mode: Conf; -*- 2 | git = 'git@github.com:pybuilder/pybuilder.git' 3 | after_clone = 'virtualenv venv && source venv/bin/activate && ./build.py install_dependencies' 4 | after_workon = 'source venv/bin/activate' 5 | 6 | # Example: 7 | # git = 'git@github.com:brocode/fw.git' 8 | # after_clone = 'echo BROCODE!!' 9 | # after_workon = 'echo workon fw' 10 | # override_path = '/some/fancy/path/to/fw' 11 | # bare = false 12 | # tags = [ 13 | # 'brocode', 14 | # 'rust', 15 | # ] 16 | # [[additional_remotes]] 17 | # name = 'upstream' 18 | # git = 'git@...' 19 | -------------------------------------------------------------------------------- /doc/example_config/settings.toml: -------------------------------------------------------------------------------- 1 | workspace = '~/workspace' 2 | shell = [ 3 | '/usr/bin/zsh', 4 | '-c', 5 | ] 6 | github_token = 'onhrefhprqrfovgrf' 7 | 8 | # Example: 9 | # workspace = '~/workspace' 10 | # shell = [ 11 | # '/usr/bin/zsh', 12 | # '-c', 13 | # ] 14 | # default_after_workon = 'echo default after workon' 15 | # default_after_clone = 'echo default after clone' 16 | # github_token = 'githubtokensecret' 17 | -------------------------------------------------------------------------------- /doc/example_config/tags/default/brogo: -------------------------------------------------------------------------------- 1 | # -*- mode: Conf; -*- 2 | priority = 100 3 | workspace = '~/go/src/github.com/brocode/' 4 | 5 | # Example: 6 | # after_clone = 'echo after clone from tag' 7 | # after_workon = 'echo after workon from tag' 8 | # priority = 0 9 | # workspace = '/home/other' 10 | # default = false 11 | -------------------------------------------------------------------------------- /doc/example_config/tags/default/fkbr: -------------------------------------------------------------------------------- 1 | # -*- mode: Conf; -*- 2 | after_workon = 'echo fkbr' 3 | 4 | # Example: 5 | # after_clone = 'echo after clone from tag' 6 | # after_workon = 'echo after workon from tag' 7 | # priority = 0 8 | # workspace = '/home/other' 9 | # default = false 10 | -------------------------------------------------------------------------------- /doc/example_config/tags/default/git: -------------------------------------------------------------------------------- 1 | # -*- mode: Conf; -*- 2 | after_workon = 'git remote update --prune' 3 | priority = 0 # lowest tags run first 4 | 5 | # Example: 6 | # after_clone = 'echo after clone from tag' 7 | # after_workon = 'echo after workon from tag' 8 | # priority = 0 9 | # workspace = '/home/other' 10 | # default = false 11 | -------------------------------------------------------------------------------- /doc/example_config/tags/default/js: -------------------------------------------------------------------------------- 1 | # -*- mode: Conf; -*- 2 | after_workon = 'source ~/.nvm/nvm.sh' 3 | 4 | # Example: 5 | # after_clone = 'echo after clone from tag' 6 | # after_workon = 'echo after workon from tag' 7 | # priority = 0 8 | # workspace = '/home/other' 9 | # default = false 10 | -------------------------------------------------------------------------------- /doc/example_config/tags/default/python: -------------------------------------------------------------------------------- 1 | # -*- mode: Conf; -*- 2 | after_workon = 'source .venv/bin/activate' 3 | 4 | # Example: 5 | # after_clone = 'echo after clone from tag' 6 | # after_workon = 'echo after workon from tag' 7 | # priority = 0 8 | # workspace = '/home/other' 9 | # default = false 10 | -------------------------------------------------------------------------------- /doc/example_config/tags/default/rust: -------------------------------------------------------------------------------- 1 | # -*- mode: Conf; -*- 2 | after_clone = 'cargo build' 3 | after_workon = 'cargo test && rustup run nightly cargo clippy' 4 | 5 | # Example: 6 | # after_clone = 'echo after clone from tag' 7 | # after_workon = 'echo after workon from tag' 8 | # priority = 0 9 | # workspace = '/home/other' 10 | # default = false 11 | -------------------------------------------------------------------------------- /doc/example_config/tags/github/brocode/brocode: -------------------------------------------------------------------------------- 1 | # -*- mode: Conf; -*- 2 | priority = 100 3 | workspace = '~/workspace/brocode/' 4 | 5 | # Example: 6 | # after_clone = 'echo after clone from tag' 7 | # after_workon = 'echo after workon from tag' 8 | # priority = 0 9 | # workspace = '/home/other' 10 | # default = false 11 | -------------------------------------------------------------------------------- /doc/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | The best way to install fw is the rust tool cargo. 4 | 5 | ``` bash 6 | cargo install fw 7 | ``` 8 | 9 | If you are using OSX, [rustup](https://rustup.rs/) is recommended but 10 | you [should be able to use brew 11 | too](https://github.com/Homebrew/homebrew-core/pull/14490). 12 | 13 | If you\'re lucky enough to be an arch linux user: 14 | [AUR](https://aur.archlinux.org/packages/fw/) 15 | 16 | If you are running on Windows then you will have some issue compiling 17 | openssl. Please refer to compiling with rust-openssl 18 | [here](https://github.com/sfackler/rust-openssl/blob/5948898e54882c0bedd12d87569eb4dbee5bbca7/README.md#windows-msvc) 19 | 20 | ## With fzf 21 | 22 | Since we integrate with [fzf](https://github.com/junegunn/fzf) it is 23 | recommended to use that or [skim](https://github.com/lotabout/skim) for 24 | the best possible experience (`workon` and `nworkon` will be helm-style 25 | fuzzy finders). Make sure `fzf` is installed and then add this to your 26 | shell configuration: 27 | 28 | Zsh (This shell is used by the project maintainers. The support for 29 | other shells is untested by us): 30 | 31 | ``` shell-script 32 | if [[ -x "$(command -v fw)" ]]; then 33 | if [[ -x "$(command -v fzf)" ]]; then 34 | eval $(fw print-zsh-setup -f 2>/dev/null); 35 | else 36 | eval $(fw print-zsh-setup 2>/dev/null); 37 | fi; 38 | fi; 39 | ``` 40 | 41 | Bash: 42 | 43 | ``` shell-script 44 | if [[ -x "$(command -v fw)" ]]; then 45 | if [[ -x "$(command -v fzf)" ]]; then 46 | eval "$(fw print-bash-setup -f 2>/dev/null)" 47 | else 48 | eval "$(fw print-bash-setup 2>/dev/null)" 49 | fi 50 | fi 51 | ``` 52 | 53 | Fish: 54 | 55 | ``` shell-script 56 | if test -x (command -v fw) 57 | if test -x (command -v fzf) 58 | fw print-fish-setup -f | source 59 | else 60 | fw print-fish-setup | source 61 | end 62 | end 63 | ``` 64 | 65 | ## With skim 66 | 67 | We also integrate with [skim](https://github.com/lotabout/skim), you can 68 | use that instead of fzf for the best possible experience (`workon` and 69 | `nworkon` will be helm-style fuzzy finders). 70 | 71 | If you have cargo installed you can install skim like this: 72 | 73 | ``` shell-script 74 | cargo install skim 75 | ``` 76 | 77 | Make sure `skim` is installed and then add this to your shell 78 | configuration: 79 | 80 | Zsh (This shell is used by the project maintainers. The support for 81 | other shells is untested by us): 82 | 83 | ``` shell-script 84 | if [[ -x "$(command -v fw)" ]]; then 85 | if [[ -x "$(command -v sk)" ]]; then 86 | eval $(fw print-zsh-setup -s 2>/dev/null); 87 | else 88 | eval $(fw print-zsh-setup 2>/dev/null); 89 | fi; 90 | fi; 91 | ``` 92 | 93 | Bash: 94 | 95 | ``` shell-script 96 | if [[ -x "$(command -v fw)" ]]; then 97 | if [[ -x "$(command -v sk)" ]]; then 98 | eval "$(fw print-bash-setup -s 2>/dev/null)" 99 | else 100 | eval "$(fw print-bash-setup 2>/dev/null)" 101 | fi 102 | fi 103 | ``` 104 | 105 | Fish: 106 | 107 | ``` shell-script 108 | if test -x (command -v fw) 109 | if test -x (command -v sk) 110 | fw print-fish-setup -s | source 111 | else 112 | fw print-fish-setup | source 113 | end 114 | end 115 | ``` 116 | 117 | ## Without fzf or skim 118 | 119 | If you don\'t want `fzf` or `skim` integration: 120 | 121 | Zsh (This shell is used by the project maintainers. The support for 122 | other shells is untested by us): 123 | 124 | ``` shell-script 125 | if [[ -x "$(command -v fw)" ]]; then 126 | eval $(fw print-zsh-setup 2>/dev/null); 127 | fi; 128 | ``` 129 | 130 | Bash: 131 | 132 | ``` shell-script 133 | [[ -x "$(command -v fw)" ]] && eval "$(fw print-bash-setup)" 134 | ``` 135 | 136 | Fish: 137 | 138 | ``` shell-script 139 | test -x (command -v fw) && fw print-fish-setup | source 140 | ``` 141 | 142 | In this case, `workon` and `nworkon` will require an argument (the 143 | project) and will provide simple prefix-based autocompletion. You should 144 | really use the `fzf` or `skim` integration though, it\'s much better! 145 | -------------------------------------------------------------------------------- /doc/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ### Overriding the config file location / multiple config files (profiles) 4 | 5 | Just set the environment variable `FW_CONFIG_DIR`. This is also honored 6 | by `fw setup` and `fw org-import` so you can create more than one 7 | configuration this way and switch at will. 8 | 9 | ### Migrating to `fw` / Configuration 10 | 11 | Initial setup is done with 12 | 13 | ``` bash 14 | fw setup DIR 15 | ``` 16 | 17 | This will look through `DIR` (flat structure!) and inspect all git 18 | repositories, then write the configuration in your home. You can edit 19 | the configuration manually to add stuff. If you have repositories 20 | elsewhere you will need to add them manually and set the `override_path` 21 | property. The configuration is portable as long as you change the 22 | `workspace` attribute, so you can share the file with your colleagues 23 | (projects with `override_path` set won\'t be portable obviously. You can 24 | also add shell code to the `after_clone` and `after_workon` fields on a 25 | per-project basis. `after_clone` will be executed after cloning the 26 | project (interpreter is `sh`) and `after_workon` will be executed each 27 | time you `workon` into the project. 28 | 29 | If you want to pull in all projects from a GitHub organization there\'s 30 | `fw org-import ` for that (note that you need a minimal config 31 | first). 32 | 33 | ### Turn `fw` configuration into reality 34 | 35 | From now on you can 36 | 37 | ``` bash 38 | fw sync # Make sure your ssh agent has your key otherwise this command will just hang because it waits for your password (you can't enter it!). 39 | ``` 40 | 41 | which will clone all missing projects that are described by the 42 | configuration but not present in your workspace. Existing projects will 43 | be synced with the remote. That means a fast-forward is executed if 44 | possible. 45 | 46 | ### Running command across all projects 47 | 48 | There is also 49 | 50 | ``` bash 51 | fw foreach 'git remote update --prune' 52 | ``` 53 | 54 | which will run the command in all your projects using `sh`. 55 | 56 | ### Updating `fw` configuration (adding new project) 57 | 58 | Instead of cloning new projects you want to work on, I suggest adding a 59 | new project to your configuration. This can be done using the tool with 60 | 61 | ``` bash 62 | fw add git@github.com:brocode/fw.git 63 | ``` 64 | 65 | (you should run `fw` sync afterwards! If you don\'t want to sync 66 | everything use `fw sync -n`) In case you don\'t like the computed 67 | project name (the above case would be `fw`) you can override this (like 68 | with `git clone` semantics): 69 | 70 | ``` bash 71 | fw add git@github.com:brocode/fw.git my-fw-clone 72 | ``` 73 | 74 | If you\'re an emacs user you should always run 75 | 76 | ``` bash 77 | fw projectile 78 | ``` 79 | 80 | after a `sync`. This will overwrite your projectile bookmarks so that 81 | all your `fw` managed projects are known. Be careful: Anything that is 82 | not managed by fw will be lost. 83 | 84 | ## workon usage 85 | 86 | Just 87 | 88 | ``` bash 89 | workon 90 | ``` 91 | 92 | It will open a fuzzy finder which you can use to select a project. Press 93 | \ on a selection and it will drop you into the project folder 94 | and execute all the hooks. 95 | 96 | If you\'re in a pinch and just want to check something real quick, then 97 | you can use 98 | 99 | nworkon 100 | 101 | as that will no execute any post-workon hooks and simply drop you into 102 | the project folder. 103 | 104 | In case you\'re not using `fzf` integration (see above) you will need to 105 | pass an argument to `workon` / `nworkon` (the project name). It comes 106 | with simple prefix-based autocompletion. 107 | -------------------------------------------------------------------------------- /logo/fw_cmyk.eps: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brocode/fw/23a2afed7c1777dda03b841db91c0b9ca3d232ee/logo/fw_cmyk.eps -------------------------------------------------------------------------------- /logo/fw_rgb.eps: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brocode/fw/23a2afed7c1777dda03b841db91c0b9ca3d232ee/logo/fw_rgb.eps -------------------------------------------------------------------------------- /logo/fw_rgb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brocode/fw/23a2afed7c1777dda03b841db91c0b9ca3d232ee/logo/fw_rgb.png -------------------------------------------------------------------------------- /logo/fw_rgb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 15 | 19 | 20 | -------------------------------------------------------------------------------- /man/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "man" 7 | version = "0.0.1" 8 | dependencies = [ 9 | "man 0.3.0", 10 | ] 11 | 12 | [[package]] 13 | name = "man" 14 | version = "0.3.0" 15 | source = "registry+https://github.com/rust-lang/crates.io-index" 16 | checksum = "ebf5fa795187a80147b1ac10aaedcf5ffd3bbeb1838bda61801a1c9ad700a1c9" 17 | dependencies = [ 18 | "roff", 19 | ] 20 | 21 | [[package]] 22 | name = "roff" 23 | version = "0.1.0" 24 | source = "registry+https://github.com/rust-lang/crates.io-index" 25 | checksum = "e33e4fb37ba46888052c763e4ec2acfedd8f00f62897b630cadb6298b833675e" 26 | -------------------------------------------------------------------------------- /man/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "man" 3 | version = "0.0.1" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | man = "0.3" 8 | -------------------------------------------------------------------------------- /man/src/main.rs: -------------------------------------------------------------------------------- 1 | use man::prelude::*; 2 | 3 | fn main() { 4 | let page = Manual::new("fw") 5 | .about("A fast workspace manager") 6 | .author(Author::new("Brocode").email("bros@brocode.sh")) 7 | .flag( 8 | Flag::new() 9 | .short("-h") 10 | .long("--help") 11 | .help("Print help information.") 12 | ) 13 | .flag( 14 | Flag::new() 15 | .short("-q") 16 | .help("Make fw quiet.") 17 | ) 18 | .flag( 19 | Flag::new() 20 | .short("-v") 21 | .help("Sets the level of verbosity.") 22 | ) 23 | .flag( 24 | Flag::new() 25 | .short("-V") 26 | .long("--version") 27 | .help("Print version information.") 28 | ) 29 | .option( 30 | Opt::new(" ") 31 | .long("add") 32 | .help("Add project to config.") 33 | ) 34 | .option( 35 | Opt::new(" ") 36 | .long("add-remote") 37 | .help("Add remote to project.") 38 | ) 39 | .option( 40 | Opt::new("") 41 | .long("foreach") 42 | .help("Run script on each project.") 43 | ) 44 | .option( 45 | Opt::new("") 46 | .long("gen-reworkon") 47 | .help("Generate sourceable shell code to re-work on project.") 48 | ) 49 | .option( 50 | Opt::new("") 51 | .long("gen-workon") 52 | .help("Generate sourceable shell code to work on project.") 53 | ) 54 | .option( 55 | Opt::new("") 56 | .long("help") 57 | .help("Print the help message or the help of the given subcommand(s).") 58 | ) 59 | .option( 60 | Opt::new("") 61 | .long("import") 62 | .help("Import existing git folder into fw.") 63 | ) 64 | .option( 65 | Opt::new("") 66 | .long("inspect") 67 | .help("Inspect project.") 68 | ) 69 | .option( 70 | Opt::new("") 71 | .long("intellij") 72 | .help("Add projects to intellijs list of recent projects.") 73 | ) 74 | .option( 75 | Opt::new("") 76 | .long("ls") 77 | .help("List projects.") 78 | ) 79 | .option( 80 | Opt::new("") 81 | .long("org-import") 82 | .help("Import all repositories from github org into fw. Token can be set in the settings file or provided via the environment variable FW_GITHUB_TOKEN.") 83 | ) 84 | .option( 85 | Opt::new("") 86 | .long("print-bash-setup") 87 | .help("Prints bash completion code.") 88 | ) 89 | .option( 90 | Opt::new("") 91 | .long("print-fish-setup") 92 | .help("Prints fish completion code.") 93 | ) 94 | .option( 95 | Opt::new("") 96 | .long("print-path") 97 | .help("Print project path on stdout.") 98 | ) 99 | .option( 100 | Opt::new("") 101 | .long("print-zsh-setup") 102 | .help("Print zsh completion code.") 103 | ) 104 | .option( 105 | Opt::new("") 106 | .long("projectile") 107 | .help("Write projectile bookmarks.") 108 | ) 109 | .option( 110 | Opt::new("") 111 | .long("remove") 112 | .help("Remove project from config.") 113 | ) 114 | .option( 115 | Opt::new(" ") 116 | .long("remove-remote") 117 | .help("Removes remote from project (Only in the fw configuration. An existing remote will not be deleted by sync).") 118 | ) 119 | .option( 120 | Opt::new("") 121 | .long("reworkon") 122 | .help("Re-run workon hooks for current dir (aliases: .|rw|re|fkbr).") 123 | ) 124 | .option( 125 | Opt::new("") 126 | .long("setup") 127 | .help("Setup config from existing workspace.") 128 | ) 129 | .option( 130 | Opt::new("") 131 | .long("sync") 132 | .help("Sync workspace. Clones projects or updates remotes for existing projects.") 133 | ) 134 | .option( 135 | Opt::new("") 136 | .long("tag") 137 | .help("Allows working with tags.") 138 | ) 139 | .option( 140 | Opt::new("") 141 | .long("update") 142 | .help("Modifies project settings.") 143 | ) 144 | .custom( 145 | Section::new("Why fw?") 146 | .paragraph("With fw you have a configuration describing your workspace. It takes care of cloning projects and can run commands across your entire workspace. You can start working on any project quickly, even if it’s not in your flat structured workspace (better than CDPATH!). It also “sets up” your environment when you start working on a project (compile stuff, run make, activate virtualenv or nvm, fire up sbt shell, etc.") 147 | ) 148 | .custom( 149 | Section::new("What this is, and isn't") 150 | .paragraph("fw is a tool I wrote to do my bidding. It might not work for you if your workflow differs a lot from mine or might require adjustments. Here are the assumptions:") 151 | .paragraph("* only git repositories") 152 | .paragraph("* only ssh clone (easily resolveable by putting more work in the git2 bindings usage") 153 | .paragraph("* ssh-agent based authentication") 154 | ) 155 | .custom( 156 | Section::new("If you can live with all of the above, you get:") 157 | .paragraph("* Workspace persistence ( I can rm -rf my entire workspace and have it back in a few minutes") 158 | .paragraph("* ZERO overhead project switching with the workon function (need to activate nvm ? Run sbt ? Set LCD brightness to 100% ? fw will do all that for you") 159 | .paragraph("* zsh completions on the project names for workon") 160 | .paragraph("* generate projectile configuration for all your project (no need to projectile-add-known-project every time you clone some shit, it will just work") 161 | ) 162 | .custom ( 163 | Section::new("Overriding the config file location / multiple config files (profiles)") 164 | .paragraph("Just set the environment variable FW_CONFIG_DIR. This is also honored by fw setup and fw org-import so you can create more than one configuration this way and switch at will.") 165 | ) 166 | .custom ( 167 | Section::new("Migration to fw / Configuration") 168 | .paragraph("Initial setup is done with:") 169 | .paragraph("$ fw setup DIR") 170 | .paragraph("This will look through DIR (flat structure!) and inspect all git repositories, then write the configuration in your home. You can edit the configuration manually to add stuff. If you have repositories elsewhere you will need to add them manually and set the override_path property. The configuration is portable as long as you change the workspace attribute, so you can share the file with your colleagues (projects with override_path set won’t be portable obviously. You can also add shell code to the after_clone and after_workon fields on a per-project basis. after_clone will be executed after cloning the project (interpreter is sh) and after_workon will be executed each time you workon into the project.") 171 | .paragraph("If you want to pull in all projects from a GitHub organization there’s fw org-import for that (note that you need a minimal config first).") 172 | ) 173 | .custom( 174 | Section::new("Turn fw configuration into reality") 175 | .paragraph("From now on you can") 176 | .paragraph("$ fw sync # Make sure your ssh agent has your key otherwise this command will just hang because it waits for your password (you can't enter it!).") 177 | .paragraph("which will clone all missing projects that are described by the configuration but not present in your workspace. Existing projects will be synced with the remote. That means a fast-forward is executed if possible.") 178 | ) 179 | .custom( 180 | Section::new("Running command across all projects") 181 | .paragraph("The is also") 182 | .paragraph("$ fw foreach 'git remote update --prune'") 183 | .paragraph("which will run the command in all your projects using sh.") 184 | ) 185 | .custom( 186 | Section::new("Updating fw configuration (adding new project)") 187 | .paragraph("Instead of cloning new projects you want to work on, I suggest adding a new project to your configuration. This can be done using the tool with") 188 | .paragraph("$ fw add git@github.com:brocode/fw.git") 189 | .paragraph("(you should run fw sync afterwards! If you don’t want to sync everything use fw sync -n) In case you don’t like the computed project name (the above case would be fw) you can override this (like with git clone semantics):") 190 | .paragraph("$ fw add git@github.com:brocode/fw.git my-fw-clone") 191 | .paragraph("If you're an emacs user you should always run") 192 | .paragraph("$ fw projectile") 193 | .paragraph("after a sync. This will overwrite your projectile bookmarks so that all your fw managed projects are known. Be careful: Anything that is not managed by fw will be lost.") 194 | ) 195 | .custom( 196 | Section::new("Workon usage") 197 | .paragraph("Just") 198 | .paragraph("$ workon") 199 | .paragraph("It will open a fuzzy finder which you can use to select a project. Press on a selection and it will drop you into the project folder and execute all the hooks.") 200 | .paragraph("If you’re in a pinch and just want to check something real quick, then you can use") 201 | .paragraph("$ nworkon") 202 | .paragraph("as that will no execute any post-workon hooks and simply drop you into the project folder.") 203 | .paragraph("In case you’re not using fzf integration (see above) you will need to pass an argument to workon / nworkon (the project name). It comes with simple prefix-based autocompletion.") 204 | ) 205 | .render(); 206 | println!("{}", page); 207 | } 208 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | tab_spaces = 2 2 | hard_tabs = true 3 | max_width = 160 4 | use_try_shorthand = true 5 | reorder_imports = true 6 | -------------------------------------------------------------------------------- /src/app/mod.rs: -------------------------------------------------------------------------------- 1 | use clap::{Arg, ArgAction, Command, crate_version, value_parser}; 2 | 3 | pub fn app() -> Command { 4 | let arg_with_fzf = Arg::new("with-fzf") 5 | .long("with-fzf") 6 | .short('f') 7 | .num_args(0) 8 | .action(ArgAction::SetTrue) 9 | .help("Integrate with fzf"); 10 | let arg_with_skim = Arg::new("with-skim") 11 | .long("with-skim") 12 | .short('s') 13 | .help("Integrate with skim") 14 | .conflicts_with("with-fzf") 15 | .action(ArgAction::SetTrue) 16 | .num_args(0); 17 | 18 | Command::new("fw") 19 | .version(crate_version!()) 20 | .author("Brocode ") 21 | .about( 22 | "fast workspace manager. Config set by FW_CONFIG_DIR or default. 23 | For further information please have a look at our README https://github.com/brocode/fw/blob/master/README.org", 24 | ) 25 | .subcommand_required(true) 26 | .subcommand( 27 | Command::new("sync") 28 | .about("Sync workspace. Clones projects or updates remotes for existing projects.") 29 | .arg( 30 | Arg::new("tag") 31 | .long("tag") 32 | .short('t') 33 | .help("Filter projects by tag. More than 1 is allowed.") 34 | .required(false) 35 | .num_args(1) 36 | .action(ArgAction::Append), 37 | ) 38 | .arg( 39 | Arg::new("no-fast-forward-merge") 40 | .long("no-ff-merge") 41 | .help("No fast forward merge") 42 | .action(ArgAction::SetTrue) 43 | .num_args(0), 44 | ) 45 | .arg( 46 | Arg::new("only-new") 47 | .long("only-new") 48 | .short('n') 49 | .help("Only clones projects. Skips all actions for projects already on your machine.") 50 | .num_args(0) 51 | .action(ArgAction::SetTrue), 52 | ) 53 | .arg( 54 | Arg::new("parallelism") 55 | .long("parallelism") 56 | .short('p') 57 | .default_value("8") 58 | .value_parser(clap::builder::RangedI64ValueParser::::new().range(0..=128)) 59 | .help("Sets the count of worker") 60 | .num_args(1), 61 | ), 62 | ) 63 | .subcommand( 64 | Command::new("print-zsh-setup") 65 | .about("Prints zsh completion code.") 66 | .arg(arg_with_fzf.clone()) 67 | .arg(arg_with_skim.clone()), 68 | ) 69 | .subcommand( 70 | Command::new("print-bash-setup") 71 | .about("Prints bash completion code.") 72 | .arg(arg_with_fzf.clone()) 73 | .arg(arg_with_skim.clone()), 74 | ) 75 | .subcommand( 76 | Command::new("print-fish-setup") 77 | .about("Prints fish completion code.") 78 | .arg(arg_with_fzf) 79 | .arg(arg_with_skim), 80 | ) 81 | .subcommand( 82 | Command::new("setup") 83 | .about("Setup config from existing workspace") 84 | .arg(Arg::new("WORKSPACE_DIR").value_name("WORKSPACE_DIR").index(1).required(true)), 85 | ) 86 | .subcommand( 87 | Command::new("reworkon") 88 | .aliases([".", "rw", "re", "fkbr"]) 89 | .about("Re-run workon hooks for current dir (aliases: .|rw|re|fkbr)"), 90 | ) 91 | .subcommand( 92 | Command::new("import") 93 | .about("Import existing git folder to fw") 94 | .arg(Arg::new("PROJECT_DIR").value_name("PROJECT_DIR").index(1).required(true)), 95 | ) 96 | .subcommand( 97 | Command::new("org-import") 98 | .about( 99 | "Import all repositories from github org into fw. Token can be set in the settings file or provided via the environment variable FW_GITHUB_TOKEN", 100 | ) 101 | .arg( 102 | Arg::new("include-archived") 103 | .value_name("include-archived") 104 | .long("include-archived") 105 | .short('a') 106 | .num_args(0) 107 | .action(ArgAction::SetTrue) 108 | .required(false), 109 | ) 110 | .arg(Arg::new("ORG_NAME").value_name("ORG_NAME").index(1).required(true)), 111 | ) 112 | .subcommand( 113 | Command::new("add-remote") 114 | .about("Add remote to project") 115 | .arg(Arg::new("NAME").value_name("NAME").index(1).required(true)) 116 | .arg(Arg::new("REMOTE_NAME").value_name("REMOTE_NAME").index(2).required(true)) 117 | .arg(Arg::new("URL").value_name("URL").index(3).required(true)), 118 | ) 119 | .subcommand( 120 | Command::new("remove-remote") 121 | .about("Removes remote from project (Only in the fw configuration. An existing remote will not be deleted by a sync)") 122 | .arg(Arg::new("NAME").value_name("NAME").index(1).required(true)) 123 | .arg(Arg::new("REMOTE_NAME").value_name("REMOTE_NAME").index(2).required(true)), 124 | ) 125 | .subcommand( 126 | Command::new("add") 127 | .about("Add project to config") 128 | .arg(Arg::new("NAME").value_name("NAME").index(2).required(false)) 129 | .arg(Arg::new("URL").value_name("URL").index(1).required(true)) 130 | .arg( 131 | Arg::new("override-path") 132 | .value_name("override-path") 133 | .long("override-path") 134 | .num_args(1) 135 | .required(false), 136 | ) 137 | .arg( 138 | Arg::new("after-workon") 139 | .value_name("after-workon") 140 | .long("after-workon") 141 | .num_args(1) 142 | .required(false), 143 | ) 144 | .arg( 145 | Arg::new("tag") 146 | .long("tag") 147 | .short('t') 148 | .help("Add tag to project") 149 | .required(false) 150 | .num_args(1) 151 | .action(ArgAction::Append), 152 | ) 153 | .arg( 154 | Arg::new("after-clone") 155 | .value_name("after-clone") 156 | .long("after-clone") 157 | .num_args(1) 158 | .required(false), 159 | ) 160 | .arg(Arg::new("trusted").long("trusted").num_args(0).required(false).action(ArgAction::SetTrue)), 161 | ) 162 | .subcommand( 163 | Command::new("remove") 164 | .alias("rm") 165 | .about("Remove project from config") 166 | .arg(Arg::new("NAME").value_name("NAME").index(1).required(true)) 167 | .arg( 168 | Arg::new("purge-directory") 169 | .long("purge-directory") 170 | .short('p') 171 | .help("Purges the project directory") 172 | .num_args(0) 173 | .action(ArgAction::SetTrue), 174 | ), 175 | ) 176 | .subcommand( 177 | Command::new("move") 178 | .alias("mv") 179 | .about("Moves a project (both physically and in the configuration)") 180 | .arg(Arg::new("NAME").value_name("NAME").index(1).required(true)) 181 | .arg(Arg::new("DESTINATION").value_name("DESTINATION").index(2).required(true)), 182 | ) 183 | .subcommand( 184 | Command::new("foreach") 185 | .about("Run script on each project") 186 | .arg(Arg::new("CMD").value_name("CMD").required(true)) 187 | .arg( 188 | Arg::new("parallel") 189 | .short('p') 190 | .help("Parallelism to use (default is set by rayon but probably equal to the number of cores)") 191 | .required(false) 192 | .value_parser(clap::builder::RangedI64ValueParser::::new().range(0..=128)) 193 | .num_args(1), 194 | ) 195 | .arg( 196 | Arg::new("tag") 197 | .long("tag") 198 | .short('t') 199 | .help("Filter projects by tag. More than 1 is allowed.") 200 | .required(false) 201 | .num_args(1) 202 | .action(ArgAction::Append), 203 | ), 204 | ) 205 | .subcommand( 206 | Command::new("print-path") 207 | .about("Print project path on stdout") 208 | .arg(Arg::new("PROJECT_NAME").value_name("PROJECT_NAME").index(1).required(true)), 209 | ) 210 | .subcommand(Command::new("projectile").about("Write projectile bookmarks")) 211 | .subcommand( 212 | Command::new("intellij").about("Add projects to intellijs list of recent projects").arg( 213 | Arg::new("no-warn") 214 | .long("no-warn") 215 | .short('n') 216 | .action(ArgAction::SetTrue) 217 | .num_args(0) 218 | .help("Disables warning message if more than 50 projects would be added"), 219 | ), 220 | ) 221 | .subcommand( 222 | Command::new("ls").about("List projects").arg( 223 | Arg::new("tag") 224 | .long("tag") 225 | .short('t') 226 | .help("Filter projects by tag. More than 1 is allowed.") 227 | .required(false) 228 | .num_args(1) 229 | .action(ArgAction::Append), 230 | ), 231 | ) 232 | .subcommand( 233 | Command::new("gen-workon") 234 | .about("Generate sourceable shell code to work on project") 235 | .arg(Arg::new("PROJECT_NAME").value_name("PROJECT_NAME").index(1).required(true)) 236 | .arg( 237 | Arg::new("quick") 238 | .required(false) 239 | .short('x') 240 | .action(ArgAction::SetTrue) 241 | .num_args(0) 242 | .help("Don't generate post_workon shell code, only cd into the folder"), 243 | ), 244 | ) 245 | .subcommand(Command::new("gen-reworkon").about("Generate sourceable shell code to re-work on project")) 246 | .subcommand( 247 | Command::new("inspect") 248 | .about("Inspect project") 249 | .arg(Arg::new("PROJECT_NAME").value_name("PROJECT_NAME").index(1).required(true)) 250 | .arg( 251 | Arg::new("json") 252 | .help("output json instead of cool text") 253 | .short('j') 254 | .long("json") 255 | .action(ArgAction::SetTrue) 256 | .num_args(0) 257 | .required(false), 258 | ), 259 | ) 260 | .subcommand( 261 | Command::new("update") 262 | .about("Modifies project settings.") 263 | .arg(Arg::new("NAME").value_name("NAME").required(true)) 264 | .arg(Arg::new("git").value_name("URL").long("git-url").num_args(1).required(false)) 265 | .arg( 266 | Arg::new("override-path") 267 | .value_name("override-path") 268 | .long("override-path") 269 | .num_args(1) 270 | .required(false), 271 | ) 272 | .arg( 273 | Arg::new("after-workon") 274 | .value_name("after-workon") 275 | .long("after-workon") 276 | .num_args(1) 277 | .required(false), 278 | ) 279 | .arg( 280 | Arg::new("after-clone") 281 | .value_name("after-clone") 282 | .long("after-clone") 283 | .num_args(1) 284 | .required(false), 285 | ), 286 | ) 287 | .subcommand( 288 | Command::new("tag") 289 | .alias("tags") 290 | .about("Allows working with tags.") 291 | .subcommand_required(true) 292 | .subcommand( 293 | Command::new("ls") 294 | .alias("list") 295 | .about("Lists tags") 296 | .arg(Arg::new("PROJECT_NAME").value_name("PROJECT_NAME").required(false)), 297 | ) 298 | .subcommand( 299 | Command::new("tag-project") 300 | .about("Add tag to project") 301 | .arg(Arg::new("PROJECT_NAME").value_name("PROJECT_NAME").required(true)) 302 | .arg(Arg::new("tag-name").value_name("tag").required(true)), 303 | ) 304 | .subcommand( 305 | Command::new("untag-project") 306 | .about("Removes tag from project") 307 | .arg(Arg::new("PROJECT_NAME").value_name("PROJECT_NAME").required(true)) 308 | .arg(Arg::new("tag-name").value_name("tag").required(true)), 309 | ) 310 | .subcommand( 311 | Command::new("autotag") 312 | .about("tags projects when CMD returns exit code 0") 313 | .arg(Arg::new("tag-name").value_name("tag").required(true)) 314 | .arg(Arg::new("CMD").value_name("CMD").required(true)) 315 | .arg( 316 | Arg::new("parallel") 317 | .short('p') 318 | .help("Parallelism to use (default is set by rayon but probably equal to the number of cores)") 319 | .required(false) 320 | .value_parser(clap::builder::RangedI64ValueParser::::new().range(0..=128)) 321 | .num_args(1), 322 | ), 323 | ) 324 | .subcommand( 325 | Command::new("inspect") 326 | .about("Inspect a tag") 327 | .arg(Arg::new("tag-name").value_name("tag name").required(true)), 328 | ) 329 | .subcommand( 330 | Command::new("rm") 331 | .about("Deletes a tag. Will not untag projects.") 332 | .arg(Arg::new("tag-name").value_name("tag name").required(true)), 333 | ) 334 | .subcommand( 335 | Command::new("add") 336 | .alias("update") 337 | .alias("create") 338 | .about("Creates a new tag. Replaces existing.") 339 | .arg(Arg::new("tag-name").value_name("tag name").required(true)) 340 | .arg( 341 | Arg::new("after-workon") 342 | .value_name("after-workon") 343 | .long("after-workon") 344 | .num_args(1) 345 | .required(false), 346 | ) 347 | .arg( 348 | Arg::new("priority") 349 | .value_name("priority") 350 | .long("priority") 351 | .value_parser(value_parser!(u8)) 352 | .num_args(1) 353 | .required(false), 354 | ) 355 | .arg(Arg::new("workspace").value_name("workspace").long("workspace").num_args(1).required(false)) 356 | .arg( 357 | Arg::new("after-clone") 358 | .value_name("after-clone") 359 | .long("after-clone") 360 | .num_args(1) 361 | .required(false), 362 | ), 363 | ), 364 | ) 365 | } 366 | -------------------------------------------------------------------------------- /src/config/metadata_from_repository.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeSet; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone)] 6 | pub struct MetadataFromRepository { 7 | pub tags: Option>, 8 | } 9 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::AppError; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::{BTreeMap, BTreeSet}; 4 | use std::fs::{self, File, read_to_string}; 5 | use std::io::Write; 6 | use std::path::{Path, PathBuf}; 7 | use walkdir::WalkDir; 8 | 9 | static CONF_MODE_HEADER: &str = "# -*- mode: Conf; -*-\n"; 10 | 11 | pub mod metadata_from_repository; 12 | mod path; 13 | pub mod project; 14 | pub mod settings; 15 | use path::{expand_path, fw_path}; 16 | 17 | use project::Project; 18 | use settings::{PersistedSettings, Settings, Tag}; 19 | 20 | #[derive(Serialize, Deserialize, Debug, Clone)] 21 | pub struct Config { 22 | pub projects: BTreeMap, 23 | pub settings: Settings, 24 | } 25 | 26 | pub fn read_config() -> Result { 27 | let paths = fw_path()?; 28 | 29 | let settings_raw = read_to_string(&paths.settings) 30 | .map_err(|e| AppError::RuntimeError(format!("Could not read settings file ({}): {}", paths.settings.to_string_lossy(), e)))?; 31 | 32 | let settings: PersistedSettings = toml::from_str(&settings_raw)?; 33 | 34 | let mut projects: BTreeMap = BTreeMap::new(); 35 | if paths.projects.exists() { 36 | for maybe_project_file in WalkDir::new(&paths.projects).follow_links(true) { 37 | let project_file = maybe_project_file?; 38 | if project_file.metadata()?.is_file() && !project_file.file_name().to_os_string().eq(".DS_Store") { 39 | let raw_project = read_to_string(project_file.path())?; 40 | let mut project: Project = match toml::from_str(&raw_project) { 41 | o @ Ok(_) => o, 42 | e @ Err(_) => { 43 | eprintln!("There is an issue in your config for project {}", project_file.file_name().to_string_lossy()); 44 | e 45 | } 46 | }?; 47 | 48 | project.name = project_file 49 | .file_name() 50 | .to_str() 51 | .map(ToOwned::to_owned) 52 | .ok_or(AppError::InternalError("Failed to get project name"))?; 53 | project.project_config_path = PathBuf::from(project_file.path().parent().ok_or(AppError::InternalError("Expected file to have a parent"))?) 54 | .strip_prefix(paths.projects.as_path()) 55 | .map_err(|e| AppError::RuntimeError(format!("Failed to strip prefix: {}", e)))? 56 | .to_string_lossy() 57 | .to_string(); 58 | if projects.contains_key(&project.name) { 59 | eprintln!( 60 | "Inconsistency found: project {} defined more than once. Will use the project that is found last. Results might be inconsistent.", 61 | project.name 62 | ); 63 | } 64 | projects.insert(project.name.clone(), project); 65 | } 66 | } 67 | } 68 | 69 | let mut tags: BTreeMap = BTreeMap::new(); 70 | if paths.tags.exists() { 71 | for maybe_tag_file in WalkDir::new(&paths.tags).follow_links(true) { 72 | let tag_file = maybe_tag_file?; 73 | 74 | if tag_file.metadata()?.is_file() && !tag_file.file_name().to_os_string().eq(".DS_Store") { 75 | let raw_tag = read_to_string(tag_file.path())?; 76 | let mut tag: Tag = toml::from_str(&raw_tag)?; 77 | let tag_name: String = tag_file 78 | .file_name() 79 | .to_str() 80 | .map(ToOwned::to_owned) 81 | .ok_or(AppError::InternalError("Failed to get tag name"))?; 82 | tag.tag_config_path = PathBuf::from(tag_file.path().parent().ok_or(AppError::InternalError("Expected file to have a parent"))?) 83 | .strip_prefix(paths.tags.as_path()) 84 | .map_err(|e| AppError::RuntimeError(format!("Failed to strip prefix: {}", e)))? 85 | .to_string_lossy() 86 | .to_string(); 87 | if tags.contains_key(&tag_name) { 88 | eprintln!( 89 | "Inconsistency found: tag {} defined more than once. Will use the project that is found last. Results might be inconsistent.", 90 | tag_name 91 | ); 92 | } 93 | tags.insert(tag_name, tag); 94 | } 95 | } 96 | } 97 | 98 | let default_tags: BTreeSet = tags 99 | .iter() 100 | .filter(|(_, value)| value.default.unwrap_or_default()) 101 | .map(|(key, _)| key.to_string()) 102 | .collect(); 103 | 104 | Ok(Config { 105 | projects, 106 | settings: Settings { 107 | tags: Some(tags), 108 | workspace: settings.workspace, 109 | shell: settings.shell, 110 | default_after_workon: settings.default_after_workon, 111 | default_after_clone: settings.default_after_clone, 112 | default_tags: Some(default_tags), 113 | github_token: settings.github_token, 114 | }, 115 | }) 116 | } 117 | 118 | pub fn write_settings(settings: &PersistedSettings) -> Result<(), AppError> { 119 | let paths = fw_path()?; 120 | paths.ensure_base_exists()?; 121 | 122 | let mut buffer = File::create(&paths.settings)?; 123 | let serialized = toml::to_string_pretty(settings)?; 124 | write!(buffer, "{}", serialized)?; 125 | write_example(&mut buffer, PersistedSettings::example())?; 126 | 127 | Ok(()) 128 | } 129 | 130 | pub fn write_tag(tag_name: &str, tag: &Tag) -> Result<(), AppError> { 131 | let paths = fw_path()?; 132 | paths.ensure_base_exists()?; 133 | 134 | let mut tag_path = paths.tags; 135 | tag_path.push(PathBuf::from(&tag.tag_config_path)); 136 | std::fs::create_dir_all(&tag_path) 137 | .map_err(|e| AppError::RuntimeError(format!("Failed to create tag config path '{}'. {}", tag_path.to_string_lossy(), e)))?; 138 | 139 | let mut tag_file_path = tag_path; 140 | tag_file_path.push(tag_name); 141 | 142 | let mut buffer = File::create(&tag_file_path) 143 | .map_err(|e| AppError::RuntimeError(format!("Failed to create project config file '{}'. {}", tag_file_path.to_string_lossy(), e)))?; 144 | let serialized = toml::to_string_pretty(&tag)?; 145 | write!(buffer, "{}", CONF_MODE_HEADER)?; 146 | write!(buffer, "{}", serialized)?; 147 | write_example(&mut buffer, Tag::example())?; 148 | Ok(()) 149 | } 150 | 151 | pub fn delete_tag_config(tag_name: &str, tag: &Tag) -> Result<(), AppError> { 152 | let paths = fw_path()?; 153 | paths.ensure_base_exists()?; 154 | 155 | let mut tag_file_path = paths.tags; 156 | tag_file_path.push(PathBuf::from(&tag.tag_config_path)); 157 | tag_file_path.push(tag_name); 158 | 159 | fs::remove_file(&tag_file_path).map_err(|e| AppError::RuntimeError(format!("Failed to delete tag config from '{:?}': {}", tag_file_path, e)))?; 160 | Ok(()) 161 | } 162 | 163 | pub fn delete_project_config(project: &Project) -> Result<(), AppError> { 164 | let paths = fw_path()?; 165 | paths.ensure_base_exists()?; 166 | 167 | let mut project_file_path = paths.projects; 168 | project_file_path.push(PathBuf::from(&project.project_config_path)); 169 | project_file_path.push(&project.name); 170 | 171 | fs::remove_file(project_file_path).map_err(|e| AppError::RuntimeError(format!("Failed to delete project config: {}", e)))?; 172 | Ok(()) 173 | } 174 | 175 | fn write_example(buffer: &mut File, example: T) -> Result<(), AppError> 176 | where 177 | T: serde::Serialize, 178 | { 179 | let example_toml = toml::to_string_pretty(&example)?; 180 | writeln!(buffer, "\n# Example:")?; 181 | for line in example_toml.split('\n') { 182 | if line.trim() != "" { 183 | writeln!(buffer, "# {}", line)?; 184 | } 185 | } 186 | Ok(()) 187 | } 188 | 189 | pub fn write_project(project: &Project) -> Result<(), AppError> { 190 | let paths = fw_path()?; 191 | paths.ensure_base_exists()?; 192 | 193 | let mut project_path = paths.projects; 194 | project_path.push(PathBuf::from(&project.project_config_path)); 195 | std::fs::create_dir_all(&project_path) 196 | .map_err(|e| AppError::RuntimeError(format!("Failed to create project config path '{}'. {}", project_path.to_string_lossy(), e)))?; 197 | 198 | let mut project_file_path = project_path; 199 | project_file_path.push(&project.name); 200 | 201 | let mut buffer: File = File::create(&project_file_path) 202 | .map_err(|e| AppError::RuntimeError(format!("Failed to create project config file '{}'. {}", project_file_path.to_string_lossy(), e)))?; 203 | let serialized = toml::to_string_pretty(&project)?; 204 | 205 | write!(buffer, "{}", CONF_MODE_HEADER)?; 206 | write!(buffer, "{}", serialized)?; 207 | write_example(&mut buffer, Project::example())?; 208 | Ok(()) 209 | } 210 | 211 | impl Config { 212 | pub fn actual_path_to_project(&self, project: &Project) -> PathBuf { 213 | let path = project 214 | .override_path 215 | .clone() 216 | .map(PathBuf::from) 217 | .unwrap_or_else(|| Path::new(self.resolve_workspace(project).as_str()).join(project.name.as_str())); 218 | expand_path(path) 219 | } 220 | 221 | fn resolve_workspace(&self, project: &Project) -> String { 222 | let mut x = self.resolve_from_tags(|tag| tag.workspace.clone(), project.tags.clone()); 223 | 224 | x.pop().unwrap_or_else(|| self.settings.workspace.clone()) 225 | } 226 | pub fn resolve_after_clone(&self, project: &Project) -> Vec { 227 | let mut commands: Vec = vec![]; 228 | commands.extend_from_slice(&self.resolve_after_clone_from_tags(project.tags.clone())); 229 | let commands_from_project: Vec = project.after_clone.clone().into_iter().collect(); 230 | commands.extend_from_slice(&commands_from_project); 231 | commands 232 | } 233 | pub fn resolve_after_workon(&self, project: &Project) -> Vec { 234 | let mut commands: Vec = vec![]; 235 | commands.extend_from_slice(&self.resolve_workon_from_tags(project.tags.clone())); 236 | let commands_from_project: Vec = project.after_workon.clone().into_iter().collect(); 237 | commands.extend_from_slice(&commands_from_project); 238 | commands 239 | } 240 | 241 | fn resolve_workon_from_tags(&self, maybe_tags: Option>) -> Vec { 242 | self.resolve_from_tags(|t| t.clone().after_workon, maybe_tags) 243 | } 244 | fn resolve_after_clone_from_tags(&self, maybe_tags: Option>) -> Vec { 245 | self.resolve_from_tags(|t| t.clone().after_clone, maybe_tags) 246 | } 247 | 248 | fn tag_priority_or_fallback(&self, tag: &Tag) -> u8 { 249 | tag.priority.unwrap_or(50) 250 | } 251 | 252 | fn resolve_from_tags(&self, resolver: F, maybe_tags: Option>) -> Vec 253 | where 254 | F: Fn(&Tag) -> Option, 255 | { 256 | if let (Some(tags), Some(settings_tags)) = (maybe_tags, self.clone().settings.tags) { 257 | let mut resolved_with_priority: Vec<(String, u8)> = tags 258 | .iter() 259 | .flat_map(|t| match settings_tags.get(t) { 260 | None => { 261 | eprintln!("Ignoring tag since it was not found in the config. missing_tag {}", t.clone()); 262 | None 263 | } 264 | Some(actual_tag) => resolver(actual_tag).map(|val| (val, self.tag_priority_or_fallback(actual_tag))), 265 | }) 266 | .collect(); 267 | resolved_with_priority.sort_by_key(|resolved_and_priority| resolved_and_priority.1); 268 | resolved_with_priority.into_iter().map(|r| r.0).collect() 269 | } else { 270 | vec![] 271 | } 272 | } 273 | } 274 | 275 | #[cfg(test)] 276 | mod tests { 277 | use super::*; 278 | use maplit::btreeset; 279 | 280 | #[test] 281 | fn test_workon_from_tags() { 282 | let config = a_config(); 283 | let resolved = config.resolve_after_workon(config.projects.get("test1").unwrap()); 284 | assert_eq!(resolved, vec!["workon1".to_string(), "workon2".to_string()]); 285 | } 286 | #[test] 287 | fn test_workon_from_tags_prioritized() { 288 | let config = a_config(); 289 | let resolved = config.resolve_after_workon(config.projects.get("test5").unwrap()); 290 | assert_eq!(resolved, vec!["workon4".to_string(), "workon3".to_string()]); 291 | } 292 | #[test] 293 | fn test_after_clone_from_tags() { 294 | let config = a_config(); 295 | let resolved = config.resolve_after_clone(config.projects.get("test1").unwrap()); 296 | assert_eq!(resolved, vec!["clone1".to_string(), "clone2".to_string()]); 297 | } 298 | #[test] 299 | fn test_after_clone_from_tags_prioritized() { 300 | let config = a_config(); 301 | let resolved = config.resolve_after_clone(config.projects.get("test5").unwrap()); 302 | assert_eq!(resolved, vec!["clone4".to_string(), "clone3".to_string()]); 303 | } 304 | #[test] 305 | fn test_workon_from_tags_missing_one_tag_graceful() { 306 | let config = a_config(); 307 | let resolved = config.resolve_after_workon(config.projects.get("test2").unwrap()); 308 | assert_eq!(resolved, vec!["workon1".to_owned()]); 309 | } 310 | #[test] 311 | fn test_workon_from_tags_missing_all_tags_graceful() { 312 | let config = a_config(); 313 | let resolved = config.resolve_after_workon(config.projects.get("test4").unwrap()); 314 | assert_eq!(resolved, Vec::::new()); 315 | } 316 | #[test] 317 | fn test_after_clone_from_tags_missing_all_tags_graceful() { 318 | let config = a_config(); 319 | let resolved = config.resolve_after_clone(config.projects.get("test4").unwrap()); 320 | assert_eq!(resolved, Vec::::new()); 321 | } 322 | #[test] 323 | fn test_after_clone_from_tags_missing_one_tag_graceful() { 324 | let config = a_config(); 325 | let resolved = config.resolve_after_clone(config.projects.get("test2").unwrap()); 326 | assert_eq!(resolved, vec!["clone1".to_owned()]); 327 | } 328 | #[test] 329 | fn test_workon_override_from_project() { 330 | let config = a_config(); 331 | let resolved = config.resolve_after_workon(config.projects.get("test3").unwrap()); 332 | assert_eq!(resolved, vec!["workon1".to_string(), "workon override in project".to_owned()]); 333 | } 334 | #[test] 335 | fn test_after_clone_override_from_project() { 336 | let config = a_config(); 337 | let resolved = config.resolve_after_clone(config.projects.get("test3").unwrap()); 338 | assert_eq!(resolved, vec!["clone1".to_string(), "clone override in project".to_owned()]); 339 | } 340 | 341 | fn a_config() -> Config { 342 | let project = Project { 343 | name: "test1".to_owned(), 344 | git: "irrelevant".to_owned(), 345 | tags: Some(btreeset!["tag1".to_owned(), "tag2".to_owned()]), 346 | after_clone: None, 347 | after_workon: None, 348 | override_path: None, 349 | additional_remotes: None, 350 | bare: None, 351 | trusted: false, 352 | project_config_path: "".to_string(), 353 | }; 354 | let project2 = Project { 355 | name: "test2".to_owned(), 356 | git: "irrelevant".to_owned(), 357 | tags: Some(btreeset!["tag1".to_owned(), "tag-does-not-exist".to_owned(),]), 358 | after_clone: None, 359 | after_workon: None, 360 | override_path: None, 361 | additional_remotes: None, 362 | bare: None, 363 | trusted: false, 364 | project_config_path: "".to_string(), 365 | }; 366 | let project3 = Project { 367 | name: "test3".to_owned(), 368 | git: "irrelevant".to_owned(), 369 | tags: Some(btreeset!["tag1".to_owned()]), 370 | after_clone: Some("clone override in project".to_owned()), 371 | after_workon: Some("workon override in project".to_owned()), 372 | override_path: None, 373 | additional_remotes: None, 374 | bare: None, 375 | trusted: false, 376 | project_config_path: "".to_string(), 377 | }; 378 | let project4 = Project { 379 | name: "test4".to_owned(), 380 | git: "irrelevant".to_owned(), 381 | tags: Some(btreeset!["tag-does-not-exist".to_owned()]), 382 | after_clone: None, 383 | after_workon: None, 384 | override_path: None, 385 | additional_remotes: None, 386 | bare: None, 387 | trusted: false, 388 | project_config_path: "".to_string(), 389 | }; 390 | let project5 = Project { 391 | name: "test5".to_owned(), 392 | git: "irrelevant".to_owned(), 393 | tags: Some(btreeset!["tag3".to_owned(), "tag4".to_owned()]), 394 | after_clone: None, 395 | after_workon: None, 396 | override_path: None, 397 | additional_remotes: None, 398 | bare: None, 399 | trusted: false, 400 | project_config_path: "".to_string(), 401 | }; 402 | let tag1 = Tag { 403 | after_clone: Some("clone1".to_owned()), 404 | after_workon: Some("workon1".to_owned()), 405 | priority: None, 406 | workspace: None, 407 | default: None, 408 | tag_config_path: "".to_string(), 409 | }; 410 | let tag2 = Tag { 411 | after_clone: Some("clone2".to_owned()), 412 | after_workon: Some("workon2".to_owned()), 413 | priority: None, 414 | workspace: None, 415 | default: None, 416 | tag_config_path: "".to_string(), 417 | }; 418 | let tag3 = Tag { 419 | after_clone: Some("clone3".to_owned()), 420 | after_workon: Some("workon3".to_owned()), 421 | priority: Some(100), 422 | workspace: None, 423 | default: None, 424 | tag_config_path: "".to_string(), 425 | }; 426 | let tag4 = Tag { 427 | after_clone: Some("clone4".to_owned()), 428 | after_workon: Some("workon4".to_owned()), 429 | priority: Some(0), 430 | workspace: None, 431 | default: None, 432 | tag_config_path: "".to_string(), 433 | }; 434 | let mut projects: BTreeMap = BTreeMap::new(); 435 | projects.insert("test1".to_owned(), project); 436 | projects.insert("test2".to_owned(), project2); 437 | projects.insert("test3".to_owned(), project3); 438 | projects.insert("test4".to_owned(), project4); 439 | projects.insert("test5".to_owned(), project5); 440 | let mut tags: BTreeMap = BTreeMap::new(); 441 | tags.insert("tag1".to_owned(), tag1); 442 | tags.insert("tag2".to_owned(), tag2); 443 | tags.insert("tag3".to_owned(), tag3); 444 | tags.insert("tag4".to_owned(), tag4); 445 | let settings = Settings { 446 | workspace: "/test".to_owned(), 447 | default_after_workon: None, 448 | default_after_clone: None, 449 | default_tags: None, 450 | shell: None, 451 | tags: Some(tags), 452 | github_token: None, 453 | }; 454 | Config { projects, settings } 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /src/config/path.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::AppError; 2 | use dirs::config_dir; 3 | use std::env; 4 | use std::path::PathBuf; 5 | 6 | pub struct FwPaths { 7 | pub settings: PathBuf, 8 | pub base: PathBuf, 9 | pub projects: PathBuf, 10 | pub tags: PathBuf, 11 | } 12 | 13 | impl FwPaths { 14 | pub fn ensure_base_exists(&self) -> Result<(), AppError> { 15 | std::fs::create_dir_all(&self.base).map_err(|e| AppError::RuntimeError(format!("Failed to create fw config base directory. {}", e)))?; 16 | Ok(()) 17 | } 18 | } 19 | 20 | fn do_expand(path: PathBuf, home_dir: Option) -> PathBuf { 21 | if let Some(home) = home_dir { 22 | home.join(path.strip_prefix("~").expect("only doing this if path starts with ~")) 23 | } else { 24 | path 25 | } 26 | } 27 | 28 | pub fn expand_path(path: PathBuf) -> PathBuf { 29 | if path.starts_with("~") { do_expand(path, dirs::home_dir()) } else { path } 30 | } 31 | 32 | pub fn fw_path() -> Result { 33 | let base = env::var("FW_CONFIG_DIR") 34 | .map(PathBuf::from) 35 | .ok() 36 | .map(expand_path) 37 | .or_else(|| { 38 | config_dir().map(|mut c| { 39 | c.push("fw"); 40 | c 41 | }) 42 | }) 43 | .ok_or(AppError::InternalError("Cannot resolve fw config dir"))?; 44 | 45 | let mut settings = base.clone(); 46 | 47 | let env: String = env::var_os("FW_ENV") 48 | .map(|s| s.to_string_lossy().to_string()) 49 | .map(|s| format!("{}_", s)) 50 | .unwrap_or_default() 51 | .replace('/', ""); 52 | 53 | settings.push(format!("{}settings.toml", env)); 54 | 55 | let mut projects = base.clone(); 56 | projects.push("projects"); 57 | 58 | let mut tags = base.clone(); 59 | tags.push("tags"); 60 | 61 | Ok(FwPaths { 62 | settings, 63 | base, 64 | projects, 65 | tags, 66 | }) 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use super::*; 72 | 73 | #[test] 74 | fn test_do_not_expand_path_without_tilde() { 75 | let path = PathBuf::from("/foo/bar"); 76 | assert_eq!(expand_path(path.clone()), path); 77 | } 78 | #[test] 79 | fn test_do_expand_path() { 80 | let path = PathBuf::from("~/foo/bar"); 81 | let home = PathBuf::from("/my/home"); 82 | assert_eq!(do_expand(path, Some(home)), PathBuf::from("/my/home/foo/bar")); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/config/project.rs: -------------------------------------------------------------------------------- 1 | use maplit::btreeset; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::BTreeSet; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone)] 6 | pub struct Remote { 7 | pub name: String, 8 | pub git: String, 9 | } 10 | 11 | #[derive(Serialize, Deserialize, Debug, Clone)] 12 | pub struct Project { 13 | #[serde(skip)] 14 | pub name: String, 15 | 16 | #[serde(default)] 17 | pub trusted: bool, 18 | 19 | pub git: String, 20 | pub after_clone: Option, 21 | pub after_workon: Option, 22 | pub override_path: Option, 23 | pub bare: Option, 24 | pub tags: Option>, 25 | pub additional_remotes: Option>, 26 | 27 | #[serde(skip)] 28 | pub project_config_path: String, 29 | } 30 | 31 | impl Project { 32 | pub fn example() -> Project { 33 | Project { 34 | name: "fw".to_owned(), 35 | git: "git@github.com:brocode/fw.git".to_owned(), 36 | tags: Some(btreeset!["rust".to_owned(), "brocode".to_owned()]), 37 | after_clone: Some("echo BROCODE!!".to_string()), 38 | after_workon: Some("echo workon fw".to_string()), 39 | override_path: Some("/some/fancy/path/to/fw".to_string()), 40 | additional_remotes: Some(vec![Remote { 41 | name: "upstream".to_string(), 42 | git: "git@...".to_string(), 43 | }]), 44 | bare: Some(false), 45 | trusted: false, 46 | project_config_path: "".to_string(), // ignored 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/config/settings.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::collections::{BTreeMap, BTreeSet}; 3 | 4 | #[derive(Serialize, Deserialize, Debug, Clone)] 5 | pub struct Tag { 6 | pub after_clone: Option, 7 | pub after_workon: Option, 8 | pub priority: Option, 9 | pub workspace: Option, 10 | pub default: Option, 11 | 12 | #[serde(skip)] 13 | pub tag_config_path: String, 14 | } 15 | 16 | impl Tag { 17 | pub fn example() -> Tag { 18 | Tag { 19 | after_clone: Some("echo after clone from tag".to_owned()), 20 | after_workon: Some("echo after workon from tag".to_owned()), 21 | priority: Some(0), 22 | workspace: Some("/home/other".to_string()), 23 | default: Some(false), 24 | tag_config_path: "".to_string(), // ignored 25 | } 26 | } 27 | } 28 | 29 | #[derive(Serialize, Deserialize, Debug, Clone)] 30 | pub struct Settings { 31 | pub workspace: String, 32 | pub shell: Option>, 33 | pub default_after_workon: Option, 34 | pub default_after_clone: Option, 35 | pub default_tags: Option>, 36 | pub tags: Option>, 37 | pub github_token: Option, 38 | } 39 | 40 | impl Settings { 41 | pub fn get_shell_or_default(self: &Settings) -> Vec { 42 | self.shell.clone().unwrap_or_else(|| vec!["sh".to_owned(), "-c".to_owned()]) 43 | } 44 | } 45 | 46 | #[derive(Deserialize, Serialize, Debug, Clone)] 47 | pub struct PersistedSettings { 48 | pub workspace: String, 49 | pub shell: Option>, 50 | pub default_after_workon: Option, 51 | pub default_after_clone: Option, 52 | pub github_token: Option, 53 | } 54 | 55 | impl PersistedSettings { 56 | pub fn example() -> PersistedSettings { 57 | PersistedSettings { 58 | workspace: "~/workspace".to_owned(), 59 | default_after_workon: Some("echo default after workon".to_string()), 60 | default_after_clone: Some("echo default after clone".to_string()), 61 | shell: Some(vec!["/usr/bin/zsh".to_string(), "-c".to_string()]), 62 | github_token: Some("githubtokensecret".to_string()), 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/errors/mod.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::fmt; 3 | use std::io; 4 | 5 | #[derive(Debug)] 6 | pub enum AppError { 7 | Io(io::Error), 8 | UserError(String), 9 | RuntimeError(String), 10 | BadJson(serde_json::Error), 11 | InternalError(&'static str), 12 | GitError(git2::Error), 13 | Regex(regex::Error), 14 | TomlSerError(toml::ser::Error), 15 | TomlDeError(toml::de::Error), 16 | WalkdirError(walkdir::Error), 17 | ReqwestError(reqwest::Error), 18 | } 19 | 20 | macro_rules! app_error_from { 21 | ($error: ty, $app_error: ident) => { 22 | impl From<$error> for AppError { 23 | fn from(err: $error) -> AppError { 24 | AppError::$app_error(err) 25 | } 26 | } 27 | }; 28 | } 29 | 30 | impl AppError { 31 | pub fn require(option: Option, app_error: AppError) -> Result { 32 | if let Some(value) = option { 33 | Result::Ok(value) 34 | } else { 35 | Result::Err(app_error) 36 | } 37 | } 38 | } 39 | 40 | impl fmt::Display for AppError { 41 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 42 | match *self { 43 | AppError::Io(ref err) => write!(f, "Io error: {}", err), 44 | AppError::UserError(ref str) => write!(f, "User error: {}", str), 45 | AppError::RuntimeError(ref str) => write!(f, "Runtime error: {}", str), 46 | AppError::BadJson(ref err) => write!(f, "JSON error: {}", err), 47 | AppError::InternalError(str) => write!(f, "Internal error: {}", str), 48 | AppError::GitError(ref err) => write!(f, "Git error: {}", err), 49 | AppError::Regex(ref err) => write!(f, "Regex error: {}", err), 50 | AppError::TomlSerError(ref err) => write!(f, "toml serialization error: {}", err), 51 | AppError::TomlDeError(ref err) => write!(f, "toml read error: {}", err), 52 | AppError::WalkdirError(ref err) => write!(f, "walkdir error: {}", err), 53 | AppError::ReqwestError(ref err) => write!(f, "reqwest error: {}", err), 54 | } 55 | } 56 | } 57 | 58 | impl Error for AppError { 59 | fn cause(&self) -> Option<&dyn Error> { 60 | match *self { 61 | AppError::Io(ref err) => Some(err), 62 | AppError::UserError(_) | AppError::RuntimeError(_) | AppError::InternalError(_) => None, 63 | AppError::BadJson(ref err) => Some(err), 64 | AppError::GitError(ref err) => Some(err), 65 | AppError::Regex(ref err) => Some(err), 66 | AppError::TomlSerError(ref err) => Some(err), 67 | AppError::TomlDeError(ref err) => Some(err), 68 | AppError::WalkdirError(ref err) => Some(err), 69 | AppError::ReqwestError(ref err) => Some(err), 70 | } 71 | } 72 | } 73 | 74 | impl From for AppError { 75 | fn from(err: core::num::ParseIntError) -> AppError { 76 | AppError::UserError(format!("Type error: {}", err)) 77 | } 78 | } 79 | 80 | app_error_from!(git2::Error, GitError); 81 | app_error_from!(io::Error, Io); 82 | app_error_from!(serde_json::Error, BadJson); 83 | app_error_from!(regex::Error, Regex); 84 | app_error_from!(toml::ser::Error, TomlSerError); 85 | app_error_from!(toml::de::Error, TomlDeError); 86 | app_error_from!(walkdir::Error, WalkdirError); 87 | app_error_from!(reqwest::Error, ReqwestError); 88 | -------------------------------------------------------------------------------- /src/git/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{Config, project::Project}; 2 | use crate::errors::AppError; 3 | 4 | use crate::spawn::spawn_maybe; 5 | use crate::util::random_color; 6 | 7 | use git2::build::RepoBuilder; 8 | use git2::{AutotagOption, Branch, Direction, FetchOptions, MergeAnalysis, ProxyOptions, Remote, RemoteCallbacks, RemoteUpdateFlags, Repository}; 9 | 10 | use std::borrow::ToOwned; 11 | 12 | use std::path::Path; 13 | 14 | pub fn repo_name_from_url(url: &str) -> Result<&str, AppError> { 15 | let last_fragment = url.rsplit('/').next().ok_or_else(|| { 16 | AppError::UserError(format!( 17 | "Given URL {} does not have path fragments so cannot determine project name. Please give \ 18 | one.", 19 | url 20 | )) 21 | })?; 22 | 23 | // trim_right_matches is more efficient but would fuck us up with repos like git@github.com:bauer/test.git.git (which is legal) 24 | Ok(if last_fragment.ends_with(".git") { 25 | last_fragment.split_at(last_fragment.len() - 4).0 26 | } else { 27 | last_fragment 28 | }) 29 | } 30 | 31 | fn agent_callbacks() -> git2::RemoteCallbacks<'static> { 32 | let mut remote_callbacks = RemoteCallbacks::new(); 33 | remote_callbacks.credentials(move |_url, username_from_url, _allowed_types| git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))); 34 | remote_callbacks 35 | } 36 | 37 | fn agent_fetch_options() -> git2::FetchOptions<'static> { 38 | let remote_callbacks = agent_callbacks(); 39 | let mut proxy_options = ProxyOptions::new(); 40 | proxy_options.auto(); 41 | let mut fetch_options = FetchOptions::new(); 42 | fetch_options.remote_callbacks(remote_callbacks); 43 | fetch_options.proxy_options(proxy_options); 44 | 45 | fetch_options 46 | } 47 | 48 | fn builder() -> RepoBuilder<'static> { 49 | let options = agent_fetch_options(); 50 | let mut repo_builder = RepoBuilder::new(); 51 | repo_builder.fetch_options(options); 52 | repo_builder 53 | } 54 | 55 | fn update_remote(remote: &mut Remote<'_>) -> Result<(), AppError> { 56 | let remote_callbacks = agent_callbacks(); 57 | let mut proxy_options = ProxyOptions::new(); 58 | proxy_options.auto(); 59 | remote 60 | .connect_auth(Direction::Fetch, Some(remote_callbacks), Some(proxy_options)) 61 | .map_err(AppError::GitError)?; 62 | let mut options = agent_fetch_options(); 63 | remote.download::(&[], Some(&mut options)).map_err(AppError::GitError)?; 64 | remote.disconnect()?; 65 | remote.update_tips(None, RemoteUpdateFlags::UPDATE_FETCHHEAD, AutotagOption::Unspecified, None)?; 66 | Ok(()) 67 | } 68 | 69 | pub fn update_project_remotes(project: &Project, path: &Path, ff_merge: bool) -> Result<(), AppError> { 70 | let local: Repository = Repository::open(path).map_err(AppError::GitError)?; 71 | for desired_remote in project.additional_remotes.clone().unwrap_or_default().into_iter().chain( 72 | vec![crate::config::project::Remote { 73 | name: "origin".to_string(), 74 | git: project.git.to_owned(), 75 | }] 76 | .into_iter(), 77 | ) { 78 | let remote = local 79 | .find_remote(&desired_remote.name) 80 | .or_else(|_| local.remote(&desired_remote.name, &desired_remote.git))?; 81 | 82 | let mut remote = match remote.url() { 83 | Some(url) if url == desired_remote.git => remote, 84 | _ => { 85 | local.remote_set_url(&desired_remote.name, &desired_remote.git)?; 86 | local.find_remote(&desired_remote.name)? 87 | } 88 | }; 89 | 90 | update_remote(&mut remote)?; 91 | } 92 | 93 | if ff_merge { 94 | // error does not matter. fast forward not possible 95 | let _ = fast_forward_merge(&local); 96 | } 97 | 98 | Ok(()) 99 | } 100 | 101 | fn fast_forward_merge(local: &Repository) -> Result<(), AppError> { 102 | let head_ref = local.head()?; 103 | if head_ref.is_branch() { 104 | let branch = Branch::wrap(head_ref); 105 | let upstream = branch.upstream()?; 106 | let upstream_commit = local.reference_to_annotated_commit(upstream.get())?; 107 | 108 | let (analysis_result, _) = local.merge_analysis(&[&upstream_commit])?; 109 | if MergeAnalysis::is_fast_forward(&analysis_result) { 110 | let target_id = upstream_commit.id(); 111 | local.checkout_tree(&local.find_object(upstream_commit.id(), None)?, None)?; 112 | local.head()?.set_target(target_id, "fw fast-forward")?; 113 | } 114 | } 115 | Ok(()) 116 | } 117 | 118 | pub fn clone_project(config: &Config, project: &Project, path: &Path) -> Result<(), AppError> { 119 | let shell = config.settings.get_shell_or_default(); 120 | let mut repo_builder = builder(); 121 | repo_builder 122 | .bare(project.bare.unwrap_or_default()) 123 | .clone(project.git.as_str(), path) 124 | .map_err(AppError::GitError) 125 | .and_then(|repo| init_additional_remotes(project, repo)) 126 | .and_then(|_| { 127 | let after_clone = config.resolve_after_clone(project); 128 | if !after_clone.is_empty() { 129 | spawn_maybe(&shell, &after_clone.join(" && "), path, &project.name, random_color()) 130 | .map_err(|error| AppError::UserError(format!("Post-clone hook failed (nonzero exit code). Cause: {:?}", error))) 131 | } else { 132 | Ok(()) 133 | } 134 | }) 135 | } 136 | 137 | fn init_additional_remotes(project: &Project, repository: Repository) -> Result<(), AppError> { 138 | if let Some(additional_remotes) = &project.additional_remotes { 139 | for remote in additional_remotes { 140 | let mut git_remote = repository.remote(&remote.name, &remote.git)?; 141 | update_remote(&mut git_remote)?; 142 | } 143 | } 144 | Ok(()) 145 | } 146 | 147 | #[cfg(test)] 148 | mod tests { 149 | use super::*; 150 | 151 | #[test] 152 | fn test_repo_name_from_url() { 153 | let https_url = "https://github.com/mriehl/fw"; 154 | let name = repo_name_from_url(https_url).unwrap().to_owned(); 155 | assert_eq!(name, "fw".to_owned()); 156 | } 157 | #[test] 158 | fn test_repo_name_from_ssh_pragma() { 159 | let ssh_pragma = "git@github.com:mriehl/fw.git"; 160 | let name = repo_name_from_url(ssh_pragma).unwrap().to_owned(); 161 | assert_eq!(name, "fw".to_owned()); 162 | } 163 | #[test] 164 | fn test_repo_name_from_ssh_pragma_with_multiple_git_endings() { 165 | let ssh_pragma = "git@github.com:mriehl/fw.git.git"; 166 | let name = repo_name_from_url(ssh_pragma).unwrap().to_owned(); 167 | assert_eq!(name, "fw.git".to_owned()); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/intellij/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use crate::errors::AppError; 3 | use std::fs; 4 | use std::io::Write; 5 | use std::option::Option::Some; 6 | use std::path::PathBuf; 7 | 8 | pub fn intellij(maybe_config: Result, warn: bool) -> Result<(), AppError> { 9 | let config: Config = maybe_config?; 10 | let projects_paths: Vec = config.projects.values().map(|p| config.actual_path_to_project(p)).collect(); 11 | let recent_projects_candidates = get_recent_projects_candidates()?; 12 | for candidate in recent_projects_candidates { 13 | let mut writer = fs::File::create(candidate)?; 14 | writeln!( 15 | writer, 16 | "")?; 27 | } 28 | 29 | let number_of_projects = projects_paths.len(); 30 | 31 | if number_of_projects > 50 && warn { 32 | print_number_of_projects_warning(number_of_projects) 33 | } 34 | 35 | Ok(()) 36 | } 37 | 38 | fn get_recent_projects_candidates() -> Result, AppError> { 39 | let mut recent_projects_candidates: Vec = Vec::new(); 40 | let mut jetbrains_dir: PathBuf = dirs::config_dir().ok_or(AppError::InternalError("Could not resolve user configuration directory"))?; 41 | jetbrains_dir.push("JetBrains"); 42 | for entry in fs::read_dir(jetbrains_dir)? { 43 | let path = entry?.path(); 44 | if let Some(directory_name) = path.file_name() { 45 | let dir = directory_name.to_string_lossy(); 46 | if dir.starts_with("IntelliJ") || dir.starts_with("Idea") { 47 | let mut recent_projects_path = path.clone(); 48 | recent_projects_path.push("options"); 49 | recent_projects_path.push("recentProjects.xml"); 50 | if recent_projects_path.exists() { 51 | recent_projects_candidates.push(recent_projects_path); 52 | } 53 | } 54 | } 55 | } 56 | Ok(recent_projects_candidates) 57 | } 58 | 59 | fn print_number_of_projects_warning(number_of_projects: usize) { 60 | print!("WARNING: {} ", number_of_projects); 61 | print!("projects were added to the list. Intellij only lists 50 projects by default. You can change this in Intellij by going to "); 62 | print!(r#"Settings -> Search for "recent" -> Pick the "Advanced Settings" -> adjust "Maximum number of recent projects"."#); 63 | println!("A high number is recommended since it won't do any harm to the system."); 64 | } 65 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::AppError; 2 | use std::collections::BTreeSet; 3 | 4 | fn main() { 5 | unsafe { 6 | openssl_probe::init_openssl_env_vars(); 7 | } 8 | let return_code = _main(); 9 | std::process::exit(return_code) 10 | } 11 | 12 | fn _main() -> i32 { 13 | let matches = crate::app::app().get_matches(); 14 | 15 | let config = config::read_config(); 16 | if config.is_err() { 17 | eprintln!( 18 | "Could not read v2.0 config: {:?}. If you are running the setup right now this is expected.", 19 | config 20 | ); 21 | }; 22 | 23 | let subcommand_name = matches.subcommand_name().expect("subcommand required by clap.rs").to_owned(); 24 | let subcommand_matches = matches.subcommand_matches(&subcommand_name).expect("subcommand matches enforced by clap.rs"); 25 | 26 | let result: Result<(), AppError> = match subcommand_name.as_ref() { 27 | "sync" => { 28 | let worker = subcommand_matches.get_one::("parallelism").expect("enforced by clap.rs").to_owned(); 29 | 30 | sync::synchronize( 31 | config, 32 | subcommand_matches.get_flag("only-new"), 33 | !subcommand_matches.get_flag("no-fast-forward-merge"), 34 | &subcommand_matches 35 | .get_many::("tag") 36 | .unwrap_or_default() 37 | .map(ToOwned::to_owned) 38 | .collect(), 39 | worker, 40 | ) 41 | } 42 | "add-remote" => { 43 | let name: &str = subcommand_matches.get_one::("NAME").expect("argument required by clap.rs"); 44 | let remote_name: &str = subcommand_matches.get_one::("REMOTE_NAME").expect("argument required by clap.rs"); 45 | let url: &str = subcommand_matches.get_one::("URL").expect("argument required by clap.rs"); 46 | project::add_remote(config, name, remote_name.to_string(), url.to_string()) 47 | } 48 | "remove-remote" => { 49 | let name: &str = subcommand_matches.get_one::("NAME").expect("argument required by clap.rs"); 50 | let remote_name: &str = subcommand_matches.get_one::("REMOTE_NAME").expect("argument required by clap.rs"); 51 | project::remove_remote(config, name, remote_name.to_string()) 52 | } 53 | "add" => { 54 | let name: Option = subcommand_matches.get_one::("NAME").map(ToOwned::to_owned); 55 | let url: &str = subcommand_matches.get_one::("URL").expect("argument required by clap.rs"); 56 | let after_workon: Option = subcommand_matches.get_one::("after-workon").map(ToOwned::to_owned); 57 | let after_clone: Option = subcommand_matches.get_one::("after-clone").map(ToOwned::to_owned); 58 | let override_path: Option = subcommand_matches.get_one::("override-path").map(ToOwned::to_owned); 59 | let tags: Option> = subcommand_matches 60 | .get_many::("tag") 61 | .map(|v| v.into_iter().map(ToOwned::to_owned).collect()); 62 | let trusted = subcommand_matches.get_flag("trusted"); 63 | project::add_entry(config, name, url, after_workon, after_clone, override_path, tags, trusted) 64 | } 65 | "remove" => project::remove_project( 66 | config, 67 | subcommand_matches.get_one::("NAME").expect("argument required by clap.rs"), 68 | subcommand_matches.get_flag("purge-directory"), 69 | ), 70 | "move" => project::move_project( 71 | config, 72 | subcommand_matches.get_one::("NAME").expect("argument required by clap.rs"), 73 | subcommand_matches.get_one::("DESTINATION").expect("argument required by clap.rs"), 74 | ), 75 | "update" => { 76 | let name: &str = subcommand_matches.get_one::("NAME").expect("argument required by clap.rs"); 77 | let git: Option = subcommand_matches.get_one::("git").map(ToOwned::to_owned); 78 | let after_workon: Option = subcommand_matches.get_one::("after-workon").map(ToOwned::to_owned); 79 | let after_clone: Option = subcommand_matches.get_one::("after-clone").map(ToOwned::to_owned); 80 | let override_path: Option = subcommand_matches.get_one::("override-path").map(ToOwned::to_owned); 81 | project::update_entry(config, name, git, after_workon, after_clone, override_path) 82 | } 83 | "setup" => setup::setup(subcommand_matches.get_one::("WORKSPACE_DIR").expect("argument required by clap.rs")), 84 | "import" => setup::import( 85 | config, 86 | subcommand_matches.get_one::("PROJECT_DIR").expect("argument required by clap.rs"), 87 | ), 88 | "org-import" => setup::org_import( 89 | config, 90 | subcommand_matches.get_one::("ORG_NAME").expect("argument required by clap.rs"), 91 | subcommand_matches.get_flag("include-archived"), 92 | ), 93 | "gen-workon" => workon::r#gen( 94 | subcommand_matches.get_one::("PROJECT_NAME").expect("argument required by clap.rs"), 95 | config, 96 | subcommand_matches.get_flag("quick"), 97 | ), 98 | "gen-reworkon" => workon::gen_reworkon(config), 99 | "reworkon" => workon::reworkon(config), 100 | "inspect" => project::inspect( 101 | subcommand_matches.get_one::("PROJECT_NAME").expect("argument required by clap.rs"), 102 | config, 103 | subcommand_matches.get_flag("json"), 104 | ), 105 | "projectile" => projectile::projectile(config), 106 | "intellij" => intellij::intellij(config, !subcommand_matches.get_flag("no-warn")), 107 | "print-path" => project::print_path( 108 | config, 109 | subcommand_matches.get_one::("PROJECT_NAME").expect("argument required by clap.rs"), 110 | ), 111 | "foreach" => spawn::foreach( 112 | config, 113 | subcommand_matches.get_one::("CMD").expect("argument required by clap.rs"), 114 | &subcommand_matches 115 | .get_many::("tag") 116 | .unwrap_or_default() 117 | .map(ToOwned::to_owned) 118 | .collect(), 119 | &subcommand_matches.get_one::("parallel").map(ToOwned::to_owned), 120 | ), 121 | "print-zsh-setup" => crate::shell::print_zsh_setup(subcommand_matches.get_flag("with-fzf"), subcommand_matches.get_flag("with-skim")), 122 | "print-bash-setup" => crate::shell::print_bash_setup(subcommand_matches.get_flag("with-fzf"), subcommand_matches.get_flag("with-skim")), 123 | "print-fish-setup" => crate::shell::print_fish_setup(subcommand_matches.get_flag("with-fzf"), subcommand_matches.get_flag("with-skim")), 124 | "tag" => { 125 | let subsubcommand_name: String = subcommand_matches.subcommand_name().expect("subcommand matches enforced by clap.rs").to_owned(); 126 | let subsubcommand_matches: clap::ArgMatches = subcommand_matches 127 | .subcommand_matches(&subsubcommand_name) 128 | .expect("subcommand matches enforced by clap.rs") 129 | .to_owned(); 130 | execute_tag_subcommand(config, &subsubcommand_name, &subsubcommand_matches) 131 | } 132 | "ls" => project::ls( 133 | config, 134 | &subcommand_matches 135 | .get_many::("tag") 136 | .unwrap_or_default() 137 | .map(ToOwned::to_owned) 138 | .collect(), 139 | ), 140 | _ => Err(AppError::InternalError("Command not implemented")), 141 | } 142 | .map(|_| ()); 143 | 144 | match result { 145 | Ok(()) => 0, 146 | Err(error) => { 147 | eprintln!("Error running command: error {}", error); 148 | 1 149 | } 150 | } 151 | } 152 | 153 | fn execute_tag_subcommand(maybe_config: Result, tag_command_name: &str, tag_matches: &clap::ArgMatches) -> Result<(), AppError> { 154 | match tag_command_name { 155 | "ls" => { 156 | let maybe_project_name: Option = tag_matches.get_one::("PROJECT_NAME").map(ToOwned::to_owned); 157 | tag::list_tags(maybe_config, maybe_project_name) 158 | } 159 | "tag-project" => { 160 | let project_name: String = tag_matches 161 | .get_one::("PROJECT_NAME") 162 | .map(ToOwned::to_owned) 163 | .expect("argument enforced by clap.rs"); 164 | let tag_name: String = tag_matches 165 | .get_one::("tag-name") 166 | .map(ToOwned::to_owned) 167 | .expect("argument enforced by clap.rs"); 168 | tag::add_tag(&maybe_config?, project_name, tag_name) 169 | } 170 | "untag-project" => { 171 | let project_name: String = tag_matches 172 | .get_one::("PROJECT_NAME") 173 | .map(ToOwned::to_owned) 174 | .expect("argument enforced by clap.rs"); 175 | let tag_name: String = tag_matches 176 | .get_one::("tag-name") 177 | .map(ToOwned::to_owned) 178 | .expect("argument enforced by clap.rs"); 179 | tag::remove_tag(maybe_config, project_name, &tag_name) 180 | } 181 | "inspect" => { 182 | let tag_name: String = tag_matches 183 | .get_one::("tag-name") 184 | .map(ToOwned::to_owned) 185 | .expect("argument enforced by clap.rs"); 186 | tag::inspect_tag(maybe_config, &tag_name) 187 | } 188 | "rm" => { 189 | let tag_name: String = tag_matches 190 | .get_one::("tag-name") 191 | .map(ToOwned::to_owned) 192 | .expect("argument enforced by clap.rs"); 193 | tag::delete_tag(maybe_config, &tag_name) 194 | } 195 | "add" => { 196 | let tag_name: String = tag_matches 197 | .get_one::("tag-name") 198 | .map(ToOwned::to_owned) 199 | .expect("argument enforced by clap.rs"); 200 | let after_workon: Option = tag_matches.get_one::("after-workon").map(ToOwned::to_owned); 201 | let after_clone: Option = tag_matches.get_one::("after-clone").map(ToOwned::to_owned); 202 | let tag_workspace: Option = tag_matches.get_one::("workspace").map(ToOwned::to_owned); 203 | let priority: Option = tag_matches.get_one::("priority").map(ToOwned::to_owned); 204 | tag::create_tag(maybe_config, tag_name, after_workon, after_clone, priority, tag_workspace) 205 | } 206 | "autotag" => tag::autotag( 207 | maybe_config, 208 | tag_matches.get_one::("CMD").expect("argument required by clap.rs"), 209 | &tag_matches 210 | .get_one::("tag-name") 211 | .map(ToOwned::to_owned) 212 | .expect("argument enforced by clap.rs"), 213 | &tag_matches.get_one::("parallel").map(ToOwned::to_owned), 214 | ), 215 | _ => Result::Err(AppError::InternalError("Command not implemented")), 216 | } 217 | } 218 | 219 | mod app; 220 | mod config; 221 | mod errors; 222 | mod git; 223 | mod intellij; 224 | mod project; 225 | mod projectile; 226 | mod setup; 227 | mod shell; 228 | mod spawn; 229 | mod sync; 230 | mod tag; 231 | mod util; 232 | mod workon; 233 | mod ws; 234 | -------------------------------------------------------------------------------- /src/project/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use crate::config::Config; 3 | use crate::config::{project::Project, project::Remote}; 4 | use crate::errors::AppError; 5 | use crate::git::repo_name_from_url; 6 | use std::collections::BTreeSet; 7 | use std::{fs, path}; 8 | use yansi::Paint; 9 | 10 | pub fn add_entry( 11 | maybe_config: Result, 12 | maybe_name: Option, 13 | url: &str, 14 | after_workon: Option, 15 | after_clone: Option, 16 | override_path: Option, 17 | tags: Option>, 18 | trusted: bool, 19 | ) -> Result<(), AppError> { 20 | let name = maybe_name 21 | .ok_or_else(|| AppError::UserError(format!("No project name specified for {}", url))) 22 | .or_else(|_| repo_name_from_url(url).map(ToOwned::to_owned))?; 23 | let config: Config = maybe_config?; 24 | if config.projects.contains_key(&name) { 25 | Err(AppError::UserError(format!( 26 | "Project key {} already exists, not gonna overwrite it for you", 27 | name 28 | ))) 29 | } else { 30 | let default_after_clone = config.settings.default_after_clone.clone(); 31 | let default_after_workon = config.settings.default_after_workon.clone(); 32 | 33 | let project_tags: Option> = if tags.is_some() && config.settings.default_tags.is_some() { 34 | tags.zip(config.settings.default_tags).map(|(t1, t2)| t1.union(&t2).cloned().collect()) 35 | } else { 36 | tags.or(config.settings.default_tags) 37 | }; 38 | 39 | config::write_project(&Project { 40 | git: url.to_owned(), 41 | name, 42 | after_clone: after_clone.or(default_after_clone), 43 | after_workon: after_workon.or(default_after_workon), 44 | override_path, 45 | tags: project_tags, 46 | bare: None, 47 | additional_remotes: None, 48 | trusted, 49 | project_config_path: "default".to_string(), 50 | })?; 51 | Ok(()) 52 | } 53 | } 54 | 55 | pub fn remove_project(maybe_config: Result, project_name: &str, purge_directory: bool) -> Result<(), AppError> { 56 | let config: Config = maybe_config?; 57 | 58 | if !config.projects.contains_key(project_name) { 59 | Err(AppError::UserError(format!("Project key {} does not exist in config", project_name))) 60 | } else if let Some(project) = config.projects.get(project_name).cloned() { 61 | if purge_directory { 62 | let path = config.actual_path_to_project(&project); 63 | 64 | if path.exists() { 65 | fs::remove_dir_all(&path)?; 66 | } 67 | } 68 | config::delete_project_config(&project) 69 | } else { 70 | Err(AppError::UserError(format!("Unknown project {}", project_name))) 71 | } 72 | } 73 | 74 | pub fn add_remote(maybe_config: Result, name: &str, remote_name: String, git: String) -> Result<(), AppError> { 75 | let config: Config = maybe_config?; 76 | if !config.projects.contains_key(name) { 77 | return Err(AppError::UserError(format!("Project key {} does not exists. Can not update.", name))); 78 | } 79 | let mut project_config: Project = config.projects.get(name).expect("Already checked in the if above").clone(); 80 | let mut additional_remotes = project_config.additional_remotes.unwrap_or_default(); 81 | if additional_remotes.iter().any(|r| r.name == remote_name) { 82 | return Err(AppError::UserError(format!( 83 | "Remote {} for project {} does already exist. Can not add.", 84 | remote_name, name 85 | ))); 86 | } 87 | additional_remotes.push(Remote { name: remote_name, git }); 88 | project_config.additional_remotes = Some(additional_remotes); 89 | 90 | config::write_project(&project_config)?; 91 | Ok(()) 92 | } 93 | 94 | pub fn remove_remote(maybe_config: Result, name: &str, remote_name: String) -> Result<(), AppError> { 95 | let config: Config = maybe_config?; 96 | if !config.projects.contains_key(name) { 97 | return Err(AppError::UserError(format!("Project key {} does not exists. Can not update.", name))); 98 | } 99 | let mut project_config: Project = config.projects.get(name).expect("Already checked in the if above").clone(); 100 | let additional_remotes = project_config.additional_remotes.unwrap_or_default(); 101 | let additional_remotes = additional_remotes.into_iter().filter(|r| r.name != remote_name).collect(); 102 | project_config.additional_remotes = Some(additional_remotes); 103 | 104 | config::write_project(&project_config)?; 105 | Ok(()) 106 | } 107 | 108 | pub fn update_entry( 109 | maybe_config: Result, 110 | name: &str, 111 | git: Option, 112 | after_workon: Option, 113 | after_clone: Option, 114 | override_path: Option, 115 | ) -> Result<(), AppError> { 116 | let config: Config = maybe_config?; 117 | if name.starts_with("http") || name.starts_with("git@") { 118 | Err(AppError::UserError(format!( 119 | "{} looks like a repo URL and not like a project name, please fix", 120 | name 121 | ))) 122 | } else if !config.projects.contains_key(name) { 123 | Err(AppError::UserError(format!("Project key {} does not exists. Can not update.", name))) 124 | } else { 125 | let old_project_config: Project = config.projects.get(name).expect("Already checked in the if above").clone(); 126 | config::write_project(&Project { 127 | git: git.unwrap_or(old_project_config.git), 128 | name: old_project_config.name, 129 | after_clone: after_clone.or(old_project_config.after_clone), 130 | after_workon: after_workon.or(old_project_config.after_workon), 131 | override_path: override_path.or(old_project_config.override_path), 132 | tags: old_project_config.tags, 133 | bare: old_project_config.bare, 134 | trusted: old_project_config.trusted, 135 | additional_remotes: old_project_config.additional_remotes, 136 | project_config_path: old_project_config.project_config_path, 137 | })?; 138 | Ok(()) 139 | } 140 | } 141 | 142 | pub fn ls(maybe_config: Result, tags: &BTreeSet) -> Result<(), AppError> { 143 | let config = maybe_config?; 144 | for (name, project) in config.projects { 145 | if tags.is_empty() || project.tags.unwrap_or_default().intersection(tags).count() > 0 { 146 | println!("{}", name) 147 | } 148 | } 149 | Ok(()) 150 | } 151 | 152 | pub fn print_path(maybe_config: Result, name: &str) -> Result<(), AppError> { 153 | let config = maybe_config?; 154 | let project = config 155 | .projects 156 | .get(name) 157 | .ok_or_else(|| AppError::UserError(format!("project {} not found", name)))?; 158 | let canonical_project_path = config.actual_path_to_project(project); 159 | let path = canonical_project_path 160 | .to_str() 161 | .ok_or(AppError::InternalError("project path is not valid unicode"))?; 162 | println!("{}", path); 163 | Ok(()) 164 | } 165 | 166 | pub fn inspect(name: &str, maybe_config: Result, json: bool) -> Result<(), AppError> { 167 | let config = maybe_config?; 168 | let project = config 169 | .projects 170 | .get(name) 171 | .ok_or_else(|| AppError::UserError(format!("project {} not found", name)))?; 172 | if json { 173 | println!("{}", serde_json::to_string(project)?); 174 | return Ok(()); 175 | } 176 | let canonical_project_path = config.actual_path_to_project(project); 177 | let path = canonical_project_path 178 | .to_str() 179 | .ok_or(AppError::InternalError("project path is not valid unicode"))?; 180 | println!("{}", Paint::new(project.name.to_owned()).bold().underline()); 181 | println!("{:<20}: {}", "Path", path); 182 | println!("{:<20}: {}", "config path", project.project_config_path); 183 | let tags = project 184 | .tags 185 | .clone() 186 | .map(|t| { 187 | let project_tags: Vec = t.into_iter().collect(); 188 | project_tags.join(", ") 189 | }) 190 | .unwrap_or_else(|| "None".to_owned()); 191 | println!("{:<20}: {}", "Tags", tags); 192 | let additional_remotes = project 193 | .additional_remotes 194 | .clone() 195 | .map(|t| { 196 | let project_tags: Vec = t.into_iter().map(|r| format!("{} - {}", r.name, r.git)).collect(); 197 | project_tags.join(", ") 198 | }) 199 | .unwrap_or_else(|| "None".to_owned()); 200 | println!("{:<20}: {}", "Additional remotes", additional_remotes); 201 | let git = project.git.clone(); 202 | println!("{:<20}: {}", "Git", git); 203 | Ok(()) 204 | } 205 | 206 | pub(crate) fn move_project(maybe_config: Result, name: &str, destination: &str) -> Result<(), AppError> { 207 | let config = maybe_config?; 208 | let project = config 209 | .projects 210 | .get(name) 211 | .ok_or_else(|| AppError::UserError(format!("project {} not found", name)))?; 212 | let canonical_project_path = config.actual_path_to_project(project); 213 | 214 | let mut path = path::absolute(destination)?; 215 | 216 | fs::rename(canonical_project_path, path.clone())?; 217 | 218 | path = fs::canonicalize(path)?; 219 | let project_path = path.to_str().ok_or(AppError::InternalError("project path is not valid unicode"))?.to_owned(); 220 | update_entry(Ok(config), name, None, None, None, Some(project_path))?; 221 | Ok(()) 222 | } 223 | -------------------------------------------------------------------------------- /src/projectile/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use crate::errors::AppError; 3 | use regex::Regex; 4 | use std::borrow::ToOwned; 5 | use std::fs; 6 | use std::io; 7 | use std::io::Write; 8 | use std::path::{Path, PathBuf}; 9 | 10 | pub fn projectile(maybe_config: Result) -> Result<(), AppError> { 11 | let config: Config = maybe_config?; 12 | let projects_paths: Vec = config.projects.values().map(|p| config.actual_path_to_project(p)).collect(); 13 | let home_dir: PathBuf = dirs::home_dir().ok_or_else(|| AppError::UserError("$HOME not set".to_owned()))?; 14 | let mut projectile_bookmarks: PathBuf = home_dir.clone(); 15 | projectile_bookmarks.push(".emacs.d"); 16 | projectile_bookmarks.push("projectile-bookmarks.eld"); 17 | let writer = fs::File::create(projectile_bookmarks)?; 18 | persist(&home_dir, writer, projects_paths) 19 | } 20 | 21 | fn persist(home_dir: &Path, writer: W, paths: Vec) -> Result<(), AppError> 22 | where 23 | W: io::Write, 24 | { 25 | let paths: Vec = paths.into_iter().flat_map(|path_buf| path_buf.to_str().map(ToOwned::to_owned)).collect(); 26 | let mut buffer = io::BufWriter::new(writer); 27 | buffer.write_all(b"(")?; 28 | for path in paths { 29 | let path = replace_path_with_tilde(&path, home_dir.to_path_buf()).unwrap_or(path); 30 | buffer.write_all(format!("\"{}/\"", path).as_bytes())?; 31 | buffer.write_all(b" ")?; 32 | } 33 | buffer.write_all(b")")?; 34 | Ok(()) 35 | } 36 | 37 | fn replace_path_with_tilde(path: &str, path_to_replace: PathBuf) -> Result { 38 | let replace_string = path_to_replace.into_os_string().into_string().expect("path should be a valid string"); 39 | let mut pattern: String = "^".to_string(); 40 | pattern.push_str(&replace_string); 41 | let regex = Regex::new(&pattern)?; 42 | Ok(regex.replace_all(path, "~").into_owned()) 43 | } 44 | 45 | #[cfg(test)] 46 | mod tests { 47 | use super::*; 48 | use std::path::Path; 49 | 50 | #[test] 51 | fn test_persists_projectile_config() { 52 | use std::io::Cursor; 53 | use std::str; 54 | let mut buffer = Cursor::new(vec![0; 61]); 55 | let paths = vec![PathBuf::from("/home/mriehl/test"), PathBuf::from("/home/mriehl/go/src/github.com/test2")]; 56 | 57 | let home_dir = Path::new("/home/blubb").to_path_buf(); 58 | persist(&home_dir, &mut buffer, paths).unwrap(); 59 | 60 | assert_eq!( 61 | str::from_utf8(buffer.get_ref()).unwrap(), 62 | "(\"/home/mriehl/test/\" \"/home/mriehl/go/src/github.com/test2/\" )" 63 | ); 64 | } 65 | 66 | #[test] 67 | fn test_replace_path_with_tilde() { 68 | let home_dir = Path::new("/home/blubb").to_path_buf(); 69 | 70 | let replaced_string = replace_path_with_tilde("/home/blubb/moep/home/blubb/test.txt", home_dir).expect("should succeed"); 71 | assert_eq!(replaced_string, "~/moep/home/blubb/test.txt".to_string()); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/setup/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{self, Config, project::Project, settings::Settings}; 2 | use crate::errors::AppError; 3 | use crate::ws::github; 4 | use clap::builder::PossibleValue; 5 | use git2::Repository; 6 | use std::collections::BTreeMap; 7 | use std::env; 8 | use std::fs; 9 | use std::iter::Iterator; 10 | use std::path::{Path, PathBuf}; 11 | 12 | #[derive(Copy, Clone)] 13 | pub enum ProjectState { 14 | Active, 15 | Archived, 16 | Both, 17 | } 18 | 19 | impl clap::ValueEnum for ProjectState { 20 | fn value_variants<'a>() -> &'a [Self] { 21 | &[Self::Active, Self::Archived, Self::Both] 22 | } 23 | 24 | fn to_possible_value(&self) -> Option { 25 | match self { 26 | Self::Active => Some(PossibleValue::new("active")), 27 | Self::Archived => Some(PossibleValue::new("archived")), 28 | Self::Both => Some(PossibleValue::new("both")), 29 | } 30 | } 31 | } 32 | 33 | impl std::str::FromStr for ProjectState { 34 | type Err = AppError; 35 | 36 | fn from_str(s: &str) -> Result { 37 | match s { 38 | "active" => Ok(Self::Active), 39 | "archived" => Ok(Self::Archived), 40 | "both" => Ok(Self::Both), 41 | _ => Err(AppError::InternalError("invalid value for ProjectState")), // TODO should this be unreachable?, 42 | } 43 | } 44 | } 45 | 46 | pub fn setup(workspace_dir: &str) -> Result<(), AppError> { 47 | let path = PathBuf::from(workspace_dir); 48 | let maybe_path = if path.exists() { 49 | Ok(path) 50 | } else { 51 | Err(AppError::UserError(format!("Given workspace path {} does not exist", workspace_dir))) 52 | }; 53 | 54 | maybe_path 55 | .and_then(|path| { 56 | if path.is_absolute() { 57 | Ok(path) 58 | } else { 59 | Err(AppError::UserError(format!("Workspace path {} needs to be absolute", workspace_dir))) 60 | } 61 | }) 62 | .and_then(determine_projects) 63 | .and_then(|projects| write_new_config_with_projects(projects, workspace_dir)) 64 | } 65 | 66 | fn determine_projects(path: PathBuf) -> Result, AppError> { 67 | let workspace_path = path.clone(); 68 | 69 | let project_entries: Vec = fs::read_dir(path).and_then(Iterator::collect).map_err(AppError::Io)?; 70 | 71 | let mut projects: BTreeMap = BTreeMap::new(); 72 | for entry in project_entries { 73 | let path = entry.path(); 74 | if path.is_dir() { 75 | match entry.file_name().into_string() { 76 | Ok(name) => { 77 | let mut path_to_repo = workspace_path.clone(); 78 | path_to_repo.push(&name); 79 | match load_project(None, path_to_repo, &name) { 80 | Ok(project) => { 81 | projects.insert(project.name.clone(), project); 82 | } 83 | Err(e) => eprintln!("Error while importing folder. Skipping it. {}", e), 84 | } 85 | } 86 | Err(_) => eprintln!("Failed to parse directory name as unicode. Skipping it."), 87 | } 88 | } 89 | } 90 | 91 | Ok(projects) 92 | } 93 | 94 | pub fn org_import(maybe_config: Result, org_name: &str, include_archived: bool) -> Result<(), AppError> { 95 | let current_config = maybe_config?; 96 | let token = env::var_os("FW_GITHUB_TOKEN") 97 | .map(|s| s.to_string_lossy().to_string()) 98 | .or_else(|| current_config.settings.github_token.clone()) 99 | .ok_or_else(|| { 100 | AppError::UserError(format!( 101 | "Can't call GitHub API for org {} because no github oauth token (settings.github_token) specified in the configuration.", 102 | org_name 103 | )) 104 | })?; 105 | let mut api = github::github_api(&token)?; 106 | let org_repository_names: Vec = api.list_repositories(org_name, include_archived)?; 107 | let after_clone = current_config.settings.default_after_clone.clone(); 108 | let after_workon = current_config.settings.default_after_workon.clone(); 109 | let tags = current_config.settings.default_tags.clone(); 110 | let mut current_projects = current_config.projects; 111 | 112 | for name in org_repository_names { 113 | let p = Project { 114 | name: name.clone(), 115 | git: format!("git@github.com:{}/{}.git", org_name, name), 116 | after_clone: after_clone.clone(), 117 | after_workon: after_workon.clone(), 118 | override_path: None, 119 | tags: tags.clone(), 120 | additional_remotes: None, 121 | bare: None, 122 | trusted: false, 123 | project_config_path: org_name.to_string(), 124 | }; 125 | 126 | if current_projects.contains_key(&p.name) { 127 | // "Skipping new project from Github import because it already exists in the current fw config 128 | } else { 129 | config::write_project(&p)?; 130 | current_projects.insert(p.name.clone(), p); // to ensure no duplicated name encountered during processing 131 | } 132 | } 133 | Ok(()) 134 | } 135 | 136 | pub fn import(maybe_config: Result, path: &str) -> Result<(), AppError> { 137 | let path = fs::canonicalize(Path::new(path))?; 138 | let project_path = path.to_str().ok_or(AppError::InternalError("project path is not valid unicode"))?.to_owned(); 139 | let file_name = AppError::require(path.file_name(), AppError::UserError("Import path needs to be valid".to_string()))?; 140 | let project_name: String = file_name.to_string_lossy().into_owned(); 141 | let maybe_settings = maybe_config.ok().map(|c| c.settings); 142 | let new_project = load_project(maybe_settings, path.clone(), &project_name)?; 143 | let new_project_with_path = Project { 144 | override_path: Some(project_path), 145 | ..new_project 146 | }; 147 | config::write_project(&new_project_with_path)?; 148 | Ok(()) 149 | } 150 | 151 | fn load_project(maybe_settings: Option, path_to_repo: PathBuf, name: &str) -> Result { 152 | let repo: Repository = Repository::open(path_to_repo)?; 153 | let remote = repo.find_remote("origin")?; 154 | let url = remote 155 | .url() 156 | .ok_or_else(|| AppError::UserError(format!("invalid remote origin at {:?}", repo.path())))?; 157 | Ok(Project { 158 | name: name.to_owned(), 159 | git: url.to_owned(), 160 | after_clone: maybe_settings.clone().and_then(|s| s.default_after_clone), 161 | after_workon: maybe_settings.clone().and_then(|s| s.default_after_workon), 162 | override_path: None, 163 | additional_remotes: None, // TODO: use remotes 164 | tags: maybe_settings.and_then(|s| s.default_tags), 165 | bare: None, 166 | trusted: false, 167 | project_config_path: "default".to_string(), 168 | }) 169 | } 170 | 171 | fn write_new_config_with_projects(projects: BTreeMap, workspace_dir: &str) -> Result<(), AppError> { 172 | let settings: config::settings::PersistedSettings = config::settings::PersistedSettings { 173 | workspace: workspace_dir.to_owned(), 174 | default_after_workon: None, 175 | default_after_clone: None, 176 | shell: None, 177 | github_token: None, 178 | }; 179 | config::write_settings(&settings)?; 180 | for p in projects.values() { 181 | config::write_project(p)?; 182 | } 183 | Ok(()) 184 | } 185 | -------------------------------------------------------------------------------- /src/shell/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::AppError; 2 | 3 | pub fn print_zsh_setup(use_fzf: bool, use_skim: bool) -> Result<(), AppError> { 4 | let fw_completion = include_str!("setup.zsh"); 5 | let basic_workon = include_str!("workon.zsh"); 6 | let fzf_workon = include_str!("workon-fzf.zsh"); 7 | let skim_workon = include_str!("workon-sk.zsh"); 8 | println!("{}", fw_completion); 9 | if use_fzf { 10 | println!("{}", fzf_workon); 11 | } else if use_skim { 12 | println!("{}", skim_workon); 13 | } else { 14 | println!("{}", basic_workon); 15 | } 16 | Ok(()) 17 | } 18 | 19 | pub fn print_bash_setup(use_fzf: bool, use_skim: bool) -> Result<(), AppError> { 20 | let setup = include_str!("setup.bash"); 21 | let basic = include_str!("workon.bash"); 22 | let fzf = include_str!("workon-fzf.bash"); 23 | let skim = include_str!("workon-sk.bash"); 24 | 25 | println!("{}", setup); 26 | if use_fzf { 27 | println!("{}", fzf); 28 | } else if use_skim { 29 | println!("{}", skim) 30 | } else { 31 | println!("{}", basic); 32 | } 33 | 34 | Ok(()) 35 | } 36 | 37 | pub fn print_fish_setup(use_fzf: bool, use_skim: bool) -> Result<(), AppError> { 38 | let setup = include_str!("setup.fish"); 39 | let basic = include_str!("workon.fish"); 40 | let fzf = include_str!("workon-fzf.fish"); 41 | let skim = include_str!("workon-sk.fish"); 42 | 43 | println!("{}", setup); 44 | if use_fzf { 45 | println!("{}", fzf); 46 | } else if use_skim { 47 | println!("{}", skim); 48 | } else { 49 | println!("{}", basic); 50 | } 51 | 52 | Ok(()) 53 | } 54 | -------------------------------------------------------------------------------- /src/shell/setup.bash: -------------------------------------------------------------------------------- 1 | command -v fw >/dev/null 2>&1 && 2 | 3 | __fw_complete() 4 | { 5 | # 6 | # This is taken from bash-completion https://github.com/scop/bash-completion 7 | # 8 | _get_comp_words_by_ref() 9 | { 10 | _upvar() 11 | { 12 | if unset -v "$1"; then 13 | if (( $# == 2 )); then 14 | eval $1=\"\$2\" 15 | else 16 | eval $1=\(\"\${@:2}\"\) 17 | fi 18 | fi 19 | } 20 | 21 | _upvars() 22 | { 23 | if ! (( $# )); then 24 | echo "${FUNCNAME[0]}: usage: ${FUNCNAME[0]} [-v varname"\ 25 | "value] | [-aN varname [value ...]] ..." 1>&2 26 | return 2 27 | fi 28 | while (( $# )); do 29 | case $1 in 30 | -a*) 31 | [[ ${1#-a} ]] || { echo "bash: ${FUNCNAME[0]}: \`$1': missing"\ 32 | "number specifier" 1>&2; return 1; } 33 | printf %d "${1#-a}" &> /dev/null || { echo "bash:"\ 34 | "${FUNCNAME[0]}: \`$1': invalid number specifier" 1>&2 35 | return 1; } 36 | [[ "$2" ]] && unset -v "$2" && eval $2=\(\"\${@:3:${1#-a}}\"\) && 37 | shift $((${1#-a} + 2)) || { echo "bash: ${FUNCNAME[0]}:"\ 38 | "\`$1${2+ }$2': missing argument(s)" 1>&2; return 1; } 39 | ;; 40 | -v) 41 | [[ "$2" ]] && unset -v "$2" && eval $2=\"\$3\" && 42 | shift 3 || { echo "bash: ${FUNCNAME[0]}: $1: missing"\ 43 | "argument(s)" 1>&2; return 1; } 44 | ;; 45 | *) 46 | echo "bash: ${FUNCNAME[0]}: $1: invalid option" 1>&2 47 | return 1 ;; 48 | esac 49 | done 50 | } 51 | 52 | __reassemble_comp_words_by_ref() 53 | { 54 | local exclude i j line ref 55 | if [[ $1 ]]; then 56 | exclude="${1//[^$COMP_WORDBREAKS]}" 57 | fi 58 | 59 | printf -v "$3" %s "$COMP_CWORD" 60 | if [[ $exclude ]]; then 61 | line=$COMP_LINE 62 | for (( i=0, j=0; i < ${#COMP_WORDS[@]}; i++, j++)); do 63 | while [[ $i -gt 0 && ${COMP_WORDS[$i]} == +([$exclude]) ]]; do 64 | [[ $line != [[:blank:]]* ]] && (( j >= 2 )) && ((j--)) 65 | ref="$2[$j]" 66 | printf -v "$ref" %s "${!ref}${COMP_WORDS[i]}" 67 | [[ $i == $COMP_CWORD ]] && printf -v "$3" %s "$j" 68 | line=${line#*"${COMP_WORDS[$i]}"} 69 | [[ $line == [[:blank:]]* ]] && ((j++)) 70 | (( $i < ${#COMP_WORDS[@]} - 1)) && ((i++)) || break 2 71 | done 72 | ref="$2[$j]" 73 | printf -v "$ref" %s "${!ref}${COMP_WORDS[i]}" 74 | line=${line#*"${COMP_WORDS[i]}"} 75 | [[ $i == $COMP_CWORD ]] && printf -v "$3" %s "$j" 76 | done 77 | [[ $i == $COMP_CWORD ]] && printf -v "$3" %s "$j" 78 | else 79 | for i in ${!COMP_WORDS[@]}; do 80 | printf -v "$2[i]" %s "${COMP_WORDS[i]}" 81 | done 82 | fi 83 | } 84 | 85 | __get_cword_at_cursor_by_ref() 86 | { 87 | local cword words=() 88 | __reassemble_comp_words_by_ref "$1" words cword 89 | 90 | local i cur index=$COMP_POINT lead=${COMP_LINE:0:$COMP_POINT} 91 | if [[ $index -gt 0 && ( $lead && ${lead//[[:space:]]} ) ]]; then 92 | cur=$COMP_LINE 93 | for (( i = 0; i <= cword; ++i )); do 94 | while [[ 95 | ${#cur} -ge ${#words[i]} && 96 | "${cur:0:${#words[i]}}" != "${words[i]}" 97 | ]]; do 98 | cur="${cur:1}" 99 | [[ $index -gt 0 ]] && ((index--)) 100 | done 101 | 102 | if [[ $i -lt $cword ]]; then 103 | local old_size=${#cur} 104 | cur="${cur#"${words[i]}"}" 105 | local new_size=${#cur} 106 | index=$(( index - old_size + new_size )) 107 | fi 108 | done 109 | [[ $cur && ! ${cur//[[:space:]]} ]] && cur= 110 | [[ $index -lt 0 ]] && index=0 111 | fi 112 | 113 | local "$2" "$3" "$4" && _upvars -a${#words[@]} $2 "${words[@]}" \ 114 | -v $3 "$cword" -v $4 "${cur:0:$index}" 115 | } 116 | 117 | local exclude flag i OPTIND=1 118 | local cur cword words=() 119 | local upargs=() upvars=() vcur vcword vprev vwords 120 | 121 | while getopts "c:i:n:p:w:" flag "$@"; do 122 | case $flag in 123 | c) vcur=$OPTARG ;; 124 | i) vcword=$OPTARG ;; 125 | n) exclude=$OPTARG ;; 126 | p) vprev=$OPTARG ;; 127 | w) vwords=$OPTARG ;; 128 | esac 129 | done 130 | while [[ $# -ge $OPTIND ]]; do 131 | case ${!OPTIND} in 132 | cur) vcur=cur ;; 133 | prev) vprev=prev ;; 134 | cword) vcword=cword ;; 135 | words) vwords=words ;; 136 | *) echo "bash: $FUNCNAME(): \`${!OPTIND}': unknown argument" \ 137 | 1>&2; return 1 138 | esac 139 | let "OPTIND += 1" 140 | done 141 | 142 | __get_cword_at_cursor_by_ref "$exclude" words cword cur 143 | 144 | [[ $vcur ]] && { upvars+=("$vcur" ); upargs+=(-v $vcur "$cur" ); } 145 | [[ $vcword ]] && { upvars+=("$vcword"); upargs+=(-v $vcword "$cword"); } 146 | [[ $vprev && $cword -ge 1 ]] && { upvars+=("$vprev" ); upargs+=(-v $vprev 147 | "${words[cword - 1]}"); } 148 | [[ $vwords ]] && { upvars+=("$vwords"); upargs+=(-a${#words[@]} $vwords 149 | "${words[@]}"); } 150 | 151 | (( ${#upvars[@]} )) && local "${upvars[@]}" && _upvars "${upargs[@]}" 152 | } 153 | 154 | __fw_comp() 155 | { 156 | local cur_="${3-$cur}" 157 | 158 | case "$cur_" in 159 | --*=) 160 | ;; 161 | *) 162 | local c i=0 IFS=$' \t\n' 163 | for c in $1; do 164 | c="$c${4-}" 165 | if [[ $c == "$cur_"* ]]; then 166 | case $c in 167 | --*=*|*.) ;; 168 | *) c="$c " ;; 169 | esac 170 | COMPREPLY[i++]="${2-}$c" 171 | fi 172 | done 173 | ;; 174 | esac 175 | } 176 | 177 | __fw_commands() 178 | { 179 | local cmds=( 180 | 'add-remote' 181 | 'add' 182 | 'foreach' 183 | 'help ' 184 | 'import' 185 | 'inspect' 186 | 'ls' 187 | 'org-import' 188 | 'print-path' 189 | 'projectile' 190 | 'remove-remote' 191 | 'remove' 192 | 'reworkon' 193 | 'setup' 194 | 'sync' 195 | 'tag' 196 | 'update' 197 | ) 198 | echo "${cmds[@]}" 199 | } 200 | 201 | __fw_tags() 202 | { 203 | local tags=() 204 | while read line; do 205 | tags+=($line) 206 | done < <(fw tag ls) 207 | echo ${tags[@]} 208 | } 209 | 210 | __find_on_cmdline() { 211 | local word subcommand c=1 212 | while [ $c -lt $cword ]; do 213 | word="${words[c]}" 214 | for subcommand in $1; do 215 | if [ "$subcommand" = "$word" ]; then 216 | echo "$subcommand" 217 | return 218 | fi 219 | done 220 | ((c++)) 221 | done 222 | } 223 | 224 | _fw_add() { 225 | case "$cur" in 226 | --*) __fw_comp "--after-clone --after-workon --override-path" ; return ;; 227 | esac 228 | } 229 | 230 | _fw_add_remote() { 231 | __fw_comp "$(__fw_projects)" 232 | } 233 | 234 | _fw_foreach () { 235 | case "$prev" in 236 | --tag|-t) __fw_comp "$(__fw_tags)" ; return ;; 237 | esac 238 | 239 | case "$cur" in 240 | --*) __fw_comp "--parallel --tag" ; return ;; 241 | esac 242 | } 243 | 244 | _fw_help () { 245 | __fw_comp "$(__fw_commands)" 246 | } 247 | 248 | _fw_import () { 249 | __fw_comp "$(__fw_projects)" 250 | } 251 | 252 | _fw_inspect () { 253 | case "$cur" in 254 | --*) __fw_comp "--json" ; return ;; 255 | esac 256 | 257 | __fw_comp "$(__fw_projects)" 258 | } 259 | 260 | _fw_org_import () { 261 | case "$cur" in 262 | --*) __fw_comp "--include-archived" ; return ;; 263 | esac 264 | } 265 | 266 | _fw_print_path () { 267 | __fw_comp "$(__fw_projects)" 268 | } 269 | 270 | _fw_remove_remote () { 271 | __fw_comp "$(__fw_projects)" 272 | } 273 | 274 | _fw_remove () { 275 | case "$cur" in 276 | --*) __fw_comp "--purge-directory" ; return ;; 277 | esac 278 | 279 | __fw_comp "$(__fw_projects)" 280 | } 281 | 282 | # _fw_reworkon() { 283 | # } 284 | 285 | _fw_sync () { 286 | case "$cur" in 287 | --*) __fw_comp "--no-ff-merge --no-progress-bar --only-new --parallelism" ; return ;; 288 | esac 289 | } 290 | 291 | _fw_tag () { 292 | local subcommands='add autotag help ls rm tag-project untag-project ' 293 | local subcommand="$(__find_on_cmdline "$subcommands")" 294 | case "$subcommand,$cur" in 295 | ,*) __fw_comp "$subcommands" ;; 296 | *) 297 | local func="_fw_tag_${subcommand//-/_}" 298 | declare -f $func >/dev/null && $func && return 299 | ;; 300 | esac 301 | } 302 | 303 | _fw_tag_add() { 304 | case "$cur" in 305 | --*) __fw_comp "--after-clone --after-workon --workspace" ; return ;; 306 | esac 307 | __fw_comp "$(__fw_tags)" 308 | } 309 | 310 | _fw_tag_autotag() { 311 | case "$cur" in 312 | --*) __fw_comp "--parallel" ; return ;; 313 | esac 314 | __fw_comp "$(__fw_tags)" 315 | } 316 | 317 | _fw_tag_help() { 318 | __fw_comp 'add autotag ls rm tag-project untag-project' 319 | } 320 | 321 | _fw_tag_ls() { 322 | __fw_comp "$(__fw_projects)" 323 | } 324 | 325 | _fw_tag_rm() { 326 | __fw_comp "$(__fw_tags)" 327 | } 328 | 329 | _fw_tag_tag_project() { 330 | local project="$(__find_on_cmdline "$(__fw_projects)")" 331 | if [ -z "$project" ]; then 332 | __fw_comp "$(__fw_projects)" 333 | else 334 | __fw_comp "$(__fw_tags)" 335 | fi 336 | } 337 | 338 | _fw_tag_untag_project() { 339 | local project="$(__find_on_cmdline "$(__fw_projects)")" 340 | if [ -z "$project" ]; then 341 | __fw_comp "$(__fw_projects)" 342 | else 343 | __fw_comp "$(__fw_tags)" 344 | fi 345 | } 346 | 347 | _fw_update () { 348 | case "$prev" in 349 | --*) return ;; 350 | esac 351 | 352 | case "$cur" in 353 | --*) __fw_comp "--after-clone --after-workon --git-url --override-path" ; return ;; 354 | esac 355 | 356 | __fw_comp "$(__fw_projects)" 357 | } 358 | 359 | __fw_main() 360 | { 361 | local i command c=1 362 | while [ $c -lt $cword ]; do 363 | i="${words[c]}" 364 | case $i in 365 | --help) command="help"; break ;; 366 | -*) ;; 367 | *) command="$i"; break ;; 368 | esac 369 | ((c++)) 370 | done 371 | 372 | if [ -z "$command" ]; then 373 | case "$cur" in 374 | --*) __fw_comp " --help" ;; 375 | *) __fw_comp "$(__fw_commands)" ;; 376 | esac 377 | return 378 | fi 379 | 380 | local completion_func="_fw_${command//-/_}" 381 | declare -f $completion_func >/dev/null && $completion_func && return 382 | } 383 | 384 | __fw_wrap() 385 | { 386 | local cur words cword prev 387 | _get_comp_words_by_ref -n =: cur words cword prev 388 | __fw_main 389 | } 390 | 391 | complete -o bashdefault -o default -o nospace -F __fw_wrap $1 2>/dev/null \ 392 | || complete -o default -o nospace -F __fw_wrap $1 393 | } && 394 | 395 | __fw_complete fw 396 | 397 | # This is needed for workon's completion 398 | __fw_projects() 399 | { 400 | local projects=() 401 | while read line; do 402 | projects+=($line) 403 | done < <(fw ls) 404 | echo ${projects[@]} 405 | } 406 | -------------------------------------------------------------------------------- /src/shell/setup.fish: -------------------------------------------------------------------------------- 1 | function __fw_projects 2 | fw ls 3 | end 4 | 5 | function __fw_tags 6 | fw tag ls 7 | end 8 | 9 | function __fw_subcommands 10 | set -l __fw_subcommands_in_zsh_format \ 11 | 'sync:Sync workspace' \ 12 | 'setup:Setup config from existing workspace' \ 13 | 'import:Import existing git folder to fw' \ 14 | 'add:Add project to workspace' \ 15 | 'add-remote:Add remote to project' \ 16 | 'remove-remote:Removes remote from project' \ 17 | 'remove:Remove project from workspace' \ 18 | 'foreach:Run script on each project' \ 19 | 'projectile:Create projectile bookmarks' \ 20 | 'ls:List projects' \ 21 | 'inspect:Inspect project' \ 22 | 'update:Update project settings' \ 23 | 'tag:Manipulate tags' \ 24 | 'print-path:Print project path to stdout' \ 25 | 'org-import:Import all repositories from a github org' 26 | 27 | for subcmd in $__fw_subcommands_in_zsh_format 28 | echo (string replace -r ':' '\t' $subcmd) 29 | end 30 | end 31 | 32 | function __fw_tag_subcommands 33 | set -l __fw_tag_subcommands_in_zsh_format \ 34 | 'add:Adds a tag' \ 35 | 'rm:Removes a tag' \ 36 | 'ls:Lists tags' \ 37 | 'inspect:inspect a tag' \ 38 | 'tag-project:Add a tag to a project' \ 39 | 'untag-project:Remove a tag from a project' \ 40 | 'autotag:Execute command for every tagged project' 41 | 42 | for subcmd in $__fw_tag_subcommands_in_zsh_format 43 | echo (string replace -r ':' '\t' $subcmd) 44 | end 45 | end 46 | 47 | function __fish_fw_is_arg_n -d 'Indicates if completion is for the nth argument (ignoring options)' 48 | set -l args (__fish_print_cmd_args_without_options) 49 | 50 | test (count $args) -eq $argv[1] 51 | end 52 | 53 | function __fish_fw_needs_command 54 | __fish_fw_is_arg_n 1 55 | end 56 | 57 | function __fish_fw_command_in 58 | set -l args (__fish_print_cmd_args_without_options) 59 | 60 | contains -- $args[2] $argv 61 | end 62 | 63 | function __fish_fw_subcommand_in 64 | set -l args (__fish_print_cmd_args_without_options) 65 | 66 | contains -- $args[3] $argv 67 | end 68 | 69 | function __fish_fw_completion_for_command 70 | __fish_fw_is_arg_n 2; and __fish_fw_command_in $argv[1] 71 | end 72 | 73 | function __fish_fw_completion_for_command_subcommand 74 | __fish_fw_is_arg_n 3; and __fish_fw_command_in $argv[1]; and __fish_fw_subcommand_in $argv[2] 75 | end 76 | 77 | function __fish_fw_needs_project_arg 78 | if __fish_fw_is_arg_n 2 79 | __fish_fw_command_in add-remote remove-remote print-path inspect update remove 80 | else if __fish_fw_is_arg_n 3 and __fish_fw_command_in tag 81 | __fish_fw_subcommand_in ls tag-project untag-project 82 | else 83 | return 1 84 | end 85 | end 86 | 87 | function __fish_fw_needs_tag_arg 88 | if ! __fish_fw_command_in tag 89 | return 1 90 | end 91 | 92 | if __fish_fw_is_arg_n 3 93 | __fish_fw_subcommand_in inspect rm 94 | else if __fish_fw_is_arg_n 4 95 | __fish_fw_subcommand_in tag-project untag-project 96 | else 97 | return 1 98 | end 99 | end 100 | 101 | complete -ec fw 102 | 103 | complete -c fw -n '__fish_fw_needs_command' -f -xa '(__fw_subcommands)' 104 | complete -c fw -n '__fish_fw_command_in tag; and __fish_fw_is_arg_n 2' -f -xa '(__fw_tag_subcommands)' 105 | complete -c fw -n '__fish_fw_needs_project_arg' -f -xa '(__fw_projects)' 106 | complete -c fw -n '__fish_fw_needs_tag_arg' -f -xa '(__fw_tags)' 107 | 108 | complete -c fw -n '__fish_fw_is_arg_n 1' -s V -l version -d 'Print version information' 109 | complete -c fw -n '__fish_fw_is_arg_n 1' -s h -l help -d 'Print help information' 110 | complete -c fw -n '__fish_fw_is_arg_n 1' -s q -d 'Make fw quiet' 111 | complete -c fw -n '__fish_fw_is_arg_n 1' -s v -d 'Set the level of verbosity' 112 | 113 | complete -c fw -n 'not __fish_fw_is_arg_n 1' -l help -s h -d 'Print help information for subcommand' 114 | 115 | complete -c fw -n '__fish_fw_completion_for_command sync' -l no-ff-merge \ 116 | -d 'No fast forward merge' 117 | complete -c fw -n '__fish_fw_completion_for_command sync' -s q -l no-progress-bar 118 | complete -c fw -n '__fish_fw_completion_for_command sync' -s n -l only-new \ 119 | -d 'Only clones projects, skips all actions for projects already on your machine.' 120 | complete -c fw -n '__fish_fw_completion_for_command sync' -s p -l parallelism \ 121 | -d 'Set the number of threads' 122 | 123 | complete -c fw -n '__fish_fw_completion_for_command org-import' -s a -l include-archived 124 | 125 | complete -c fw -n '__fish_fw_completion_for_command foreach' -s p \ 126 | -d 'Set the number of threads' 127 | complete -c fw -n '__fish_fw_completion_for_command foreach' -s t -l tag \ 128 | -d 'Filter projects by tag. More than 1 is allowed.' 129 | 130 | complete -c fw -n '__fish_fw_completion_for_command ls' -s t -l tag \ 131 | -d 'Filter projects by tag. More than 1 is allowed.' 132 | 133 | complete -c fw -n '__fish_fw_completion_for_command update' -l after-clone 134 | complete -c fw -n '__fish_fw_completion_for_command update' -l after-workon 135 | complete -c fw -n '__fish_fw_completion_for_command update' -l git-url 136 | complete -c fw -n '__fish_fw_completion_for_command update' -l override-path 137 | 138 | complete -c fw -n '__fish_fw_completion_for_command_subcommand tag add' -l after-clone 139 | complete -c fw -n '__fish_fw_completion_for_command_subcommand tag add' -l after-workon 140 | complete -c fw -n '__fish_fw_completion_for_command_subcommand tag add' -l git-url 141 | complete -c fw -n '__fish_fw_completion_for_command_subcommand tag add' -l override-path 142 | 143 | complete -c fw -n '__fish_fw_completion_for_command_subcommand tag autotag' -s p \ 144 | -d 'Set the number of threads' 145 | -------------------------------------------------------------------------------- /src/shell/setup.zsh: -------------------------------------------------------------------------------- 1 | __fw_projects() { 2 | local projects; 3 | fw ls | while read line; do 4 | projects+=( $line ); 5 | done; 6 | _describe -t projects 'project names' projects; 7 | }; 8 | 9 | __fw_tags() { 10 | local tags; 11 | fw tag ls | while read line; do 12 | tags+=( $line ); 13 | done; 14 | _describe -t tags 'tag names' tags; 15 | }; 16 | 17 | _fw() { 18 | if ! command -v fw > /dev/null 2>&1; then 19 | _message "fw not installed"; 20 | else 21 | _arguments '1: :->first' '2: :->second' '3: :->third' '4: :->fourth'; 22 | 23 | case $state in 24 | first) 25 | actions=( 26 | 'sync:Sync workspace' 27 | 'setup:Setup config from existing workspace' 28 | 'import:Import existing git folder to fw' 29 | 'add:Add project to workspace' 30 | 'add-remote:Add remote to project' 31 | 'remove-remote:Removes remote from project' 32 | 'remove:Remove project from workspace' 33 | 'foreach:Run script on each project' 34 | 'projectile:Create projectile bookmarks' 35 | 'ls:List projects' 36 | 'inspect:Inspect project' 37 | 'update:Update project settings' 38 | 'tag:Manipulate tags' 39 | 'print-path:Print project path to stdout' 40 | 'org-import:Import all repositories from a github org' 41 | ); 42 | _describe action actions && ret=0; 43 | ;; 44 | second) 45 | case $words[2] in 46 | sync) 47 | _arguments '*:option:(--no-ff-merge)'; 48 | ;; 49 | org-import) 50 | _arguments '*:option:(--include-archived)'; 51 | ;; 52 | add-remote) 53 | __fw_projects; 54 | ;; 55 | remove-remote) 56 | __fw_projects; 57 | ;; 58 | print-path) 59 | __fw_projects; 60 | ;; 61 | inspect) 62 | __fw_projects; 63 | ;; 64 | update) 65 | __fw_projects; 66 | ;; 67 | remove) 68 | __fw_projects; 69 | ;; 70 | tag) 71 | actions=( 72 | 'add:Adds a tag' 73 | 'rm:Removes a tag' 74 | 'ls:Lists tags' 75 | 'inspect:inspect a tag' 76 | 'tag-project:Add a tag to a project' 77 | 'untag-project:Remove a tag from a project' 78 | 'autotag:Execute command for every tagged project' 79 | ); 80 | _describe action actions && ret=0; 81 | ;; 82 | *) 83 | ;; 84 | esac 85 | ;; 86 | third) 87 | case $words[2] in 88 | update) 89 | _arguments '*:option:(--override-path --git-url --after-clone --after-workon)'; 90 | ;; 91 | remove) 92 | _arguments '*:option:(--purge-directory)'; 93 | ;; 94 | tag) 95 | case $words[3] in 96 | tag-project) 97 | __fw_projects; 98 | ;; 99 | untag-project) 100 | __fw_projects; 101 | ;; 102 | ls) 103 | __fw_projects; 104 | ;; 105 | inspect) 106 | __fw_tags; 107 | ;; 108 | rm) 109 | __fw_tags; 110 | ;; 111 | *) 112 | ;; 113 | esac 114 | ;; 115 | *) 116 | ;; 117 | esac 118 | ;; 119 | fourth) 120 | case $words[2] in 121 | tag) 122 | case $words[3] in 123 | tag-project) 124 | __fw_tags; 125 | ;; 126 | untag-project) 127 | __fw_tags; 128 | ;; 129 | *) 130 | ;; 131 | esac 132 | ;; 133 | *) 134 | ;; 135 | esac 136 | ;; 137 | esac 138 | fi 139 | }; 140 | compdef _fw fw; 141 | -------------------------------------------------------------------------------- /src/shell/workon-fzf.bash: -------------------------------------------------------------------------------- 1 | __workon() 2 | { 3 | local PROJECT="$(fw ls | fzf --cycle --query=$1 --preview-window=top:50% --preview='fw inspect {}' --no-mouse --select-1)" 4 | local SCRIPT="$(fw gen-workon $2 $PROJECT)" 5 | case $(uname -s) in 6 | MINGW*|MSYS*) SCRIPT="cd $(echo "/${SCRIPT:3}" | sed -e 's/\\/\//g' -e 's/://')" ;; 7 | esac 8 | [ $? -eq 0 ] && eval "$SCRIPT" || printf "$SCRIPT\n" 9 | } 10 | 11 | reworkon() 12 | { 13 | local SCRIPT="$(fw gen-reworkon $@)" 14 | case $(uname -s) in 15 | MINGW*|MSYS*) SCRIPT="cd $(echo "/${SCRIPT:3}" | sed -e 's/\\/\//g' -e 's/://')" ;; 16 | esac 17 | [ $? -eq 0 ] && eval "$SCRIPT" || printf "$SCRIPT" 18 | } 19 | 20 | workon() 21 | { 22 | __workon "$1" 23 | } 24 | 25 | nworkon() 26 | { 27 | __workon "$1" "-x" 28 | } 29 | -------------------------------------------------------------------------------- /src/shell/workon-fzf.fish: -------------------------------------------------------------------------------- 1 | function __fish_fw_use_script 2 | if test $argv[1] -eq 0 3 | echo $argv[2] | source 4 | else 5 | printf "$argv[2]\n" 6 | end 7 | end 8 | 9 | function __workon 10 | set -l project (fw ls | fzf --cycle --query=$argv[1] --preview-window=top:50% --preview='fw inspect {}' --no-mouse --select-1) 11 | set -l script (fw gen-workon $argv[2] $project) 12 | __fish_fw_use_script $status $script 13 | end 14 | 15 | function workon 16 | __workon $argv[1] 17 | end 18 | 19 | function nworkon 20 | __workon "$argv[1]" -x 21 | end 22 | 23 | function reworkon 24 | set -l script (fw gen-reworkon $argv) 25 | __fish_fw_use_script $status $script 26 | end 27 | 28 | complete -c workon -f -xa "(__fw_projects)" 29 | complete -c nworkon -f -xa "(__fw_projects)" 30 | -------------------------------------------------------------------------------- /src/shell/workon-fzf.zsh: -------------------------------------------------------------------------------- 1 | __workon () { 2 | PROJECT="$(fw ls | fzf --cycle --query=$1 --preview-window=top:50% --preview='fw inspect {}' --no-mouse --select-1)" 3 | SCRIPT="$(fw gen-workon $2 $PROJECT)"; 4 | if [ $? -eq 0 ]; then 5 | eval "$SCRIPT"; 6 | else 7 | printf "$SCRIPT\n"; 8 | fi 9 | }; 10 | 11 | reworkon () { 12 | SCRIPT="$(fw gen-reworkon $@)"; 13 | if [ $? -eq 0 ]; then 14 | eval "$SCRIPT"; 15 | else 16 | printf "$SCRIPT\n"; 17 | fi 18 | }; 19 | 20 | workon () { 21 | __workon "$1" 22 | }; 23 | 24 | nworkon () { 25 | __workon "$1" "-x" 26 | }; 27 | -------------------------------------------------------------------------------- /src/shell/workon-sk.bash: -------------------------------------------------------------------------------- 1 | __workon() 2 | { 3 | local PROJECT="$(fw ls | sk --query=$1 --preview-window=up:50% --preview='fw inspect {}' --no-mouse --select-1)" 4 | local SCRIPT="$(fw gen-workon $2 $PROJECT)" 5 | case $(uname -s) in 6 | MINGW*|MSYS*) SCRIPT="cd $(echo "/${SCRIPT:3}" | sed -e 's/\\/\//g' -e 's/://')" ;; 7 | esac 8 | [ $? -eq 0 ] && eval "$SCRIPT" || printf "$SCRIPT\n" 9 | } 10 | 11 | reworkon() 12 | { 13 | local SCRIPT="$(fw gen-reworkon $@)" 14 | case $(uname -s) in 15 | MINGW*|MSYS*) SCRIPT="cd $(echo "/${SCRIPT:3}" | sed -e 's/\\/\//g' -e 's/://')" ;; 16 | esac 17 | [ $? -eq 0 ] && eval "$SCRIPT" || printf "$SCRIPT" 18 | } 19 | 20 | workon() 21 | { 22 | __workon "$1" 23 | } 24 | 25 | nworkon() 26 | { 27 | __workon "$1" "-x" 28 | } 29 | -------------------------------------------------------------------------------- /src/shell/workon-sk.fish: -------------------------------------------------------------------------------- 1 | function __fish_fw_use_script 2 | if test $argv[1] -eq 0 3 | echo $argv[2] | source 4 | else 5 | printf "$argv[2]\n" 6 | end 7 | end 8 | 9 | function __workon 10 | set -l project (fw ls | sk --query=$argv[1] --preview-window=up:50% --preview='fw inspect {}' --no-mouse --select-1) 11 | set -l script (fw gen-workon $argv[2] $project) 12 | __fish_fw_use_script $status $script 13 | end 14 | 15 | function workon 16 | __workon $argv[1] 17 | end 18 | 19 | function nworkon 20 | __workon "$argv[1]" -x 21 | end 22 | 23 | function reworkon 24 | set -l script (fw gen-reworkon $argv) 25 | __fish_fw_use_script $status $script 26 | end 27 | 28 | complete -c workon -f -xa "(__fw_projects)" 29 | complete -c nworkon -f -xa "(__fw_projects)" 30 | -------------------------------------------------------------------------------- /src/shell/workon-sk.zsh: -------------------------------------------------------------------------------- 1 | __workon () { 2 | PROJECT="$(fw ls | sk --query=$1 --preview-window=up:50% --preview='fw inspect {}' --no-mouse --select-1)" 3 | SCRIPT="$(fw gen-workon $2 $PROJECT)"; 4 | if [ $? -eq 0 ]; then 5 | eval "$SCRIPT"; 6 | else 7 | printf "$SCRIPT\n"; 8 | fi 9 | }; 10 | 11 | reworkon () { 12 | SCRIPT="$(fw gen-reworkon $@)"; 13 | if [ $? -eq 0 ]; then 14 | eval "$SCRIPT"; 15 | else 16 | printf "$SCRIPT\n"; 17 | fi 18 | }; 19 | 20 | workon () { 21 | __workon "$1" 22 | }; 23 | 24 | nworkon () { 25 | __workon "$1" "-x" 26 | }; 27 | -------------------------------------------------------------------------------- /src/shell/workon.bash: -------------------------------------------------------------------------------- 1 | workon() 2 | { 3 | local SCRIPT="$(fw gen-workon $@)" 4 | case $(uname -s) in 5 | MINGW*|MSYS*) SCRIPT="cd $(echo "/${SCRIPT:3}" | sed -e 's/\\/\//g' -e 's/://')" ;; 6 | esac 7 | [ $? -eq 0 ] && eval "$SCRIPT" || printf "$SCRIPT" 8 | } 9 | 10 | reworkon() 11 | { 12 | local SCRIPT="$(fw gen-reworkon $@)" 13 | case $(uname -s) in 14 | MINGW*|MSYS*) SCRIPT="cd $(echo "/${SCRIPT:3}" | sed -e 's/\\/\//g' -e 's/://')" ;; 15 | esac 16 | [ $? -eq 0 ] && eval "$SCRIPT" || printf "$SCRIPT" 17 | } 18 | 19 | reworkon() 20 | { 21 | local SCRIPT="$(fw gen-workon -x $@)" 22 | case $(uname -s) in 23 | MINGW*|MSYS*) SCRIPT="cd $(echo "/${SCRIPT:3}" | sed -e 's/\\/\//g' -e 's/://')" ;; 24 | esac 25 | [ $? -eq 0 ] && eval "$SCRIPT" || printf "$SCRIPT" 26 | } 27 | 28 | _workon() 29 | { 30 | COMPREPLY=($(compgen -W "$(__fw_projects)" -- ${COMP_WORDS[1]})) 31 | } 32 | 33 | complete -F _workon workon 34 | -------------------------------------------------------------------------------- /src/shell/workon.fish: -------------------------------------------------------------------------------- 1 | function __fish_fw_use_script 2 | if test $argv[1] -eq 0 3 | echo $argv[2] | source 4 | else 5 | printf "$argv[2]\n" 6 | end 7 | end 8 | 9 | function workon 10 | set -l script (fw gen-workon $argv) 11 | __fish_fw_use_script $status $script 12 | end 13 | 14 | function nworkon 15 | set -l script (fw gen-workon -x $argv) 16 | __fish_fw_use_script $status $script 17 | end 18 | 19 | function reworkon 20 | set -l script (fw gen-reworkon $argv) 21 | __fish_fw_use_script $status $script 22 | end 23 | 24 | complete -c workon -f -xa "(__fw_projects)" 25 | complete -c nworkon -f -xa "(__fw_projects)" 26 | -------------------------------------------------------------------------------- /src/shell/workon.zsh: -------------------------------------------------------------------------------- 1 | workon () { 2 | SCRIPT="$(fw gen-workon $@)"; 3 | if [ $? -eq 0 ]; then 4 | eval "$SCRIPT"; 5 | else 6 | printf "$SCRIPT\n"; 7 | fi 8 | }; 9 | reworkon () { 10 | SCRIPT="$(fw gen-reworkon $@)"; 11 | if [ $? -eq 0 ]; then 12 | eval "$SCRIPT"; 13 | else 14 | printf "$SCRIPT\n"; 15 | fi 16 | }; 17 | 18 | nworkon () { 19 | SCRIPT="$(fw gen-workon -x $@)"; 20 | if [ $? -eq 0 ]; then 21 | eval "$SCRIPT"; 22 | else 23 | printf "$SCRIPT\n"; 24 | fi 25 | }; 26 | 27 | _workon() { 28 | if ! command -v fw > /dev/null 2>&1; then 29 | _message "fw not installed"; 30 | else 31 | __fw_projects; 32 | fi 33 | }; 34 | 35 | compdef _workon workon; 36 | compdef _workon nworkon; 37 | -------------------------------------------------------------------------------- /src/spawn/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{Config, project::Project}; 2 | use crate::errors::AppError; 3 | 4 | use rayon::prelude::*; 5 | use std::collections::BTreeSet; 6 | use yansi::{Color, Paint}; 7 | 8 | use std::borrow::ToOwned; 9 | 10 | use crate::util::random_color; 11 | use std::io::IsTerminal; 12 | use std::io::{BufRead, BufReader}; 13 | use std::path::Path; 14 | use std::process::{Child, Command, Stdio}; 15 | 16 | use std::thread; 17 | 18 | fn forward_process_output_to_stdout(read: T, prefix: &str, color: Color, atty: bool, mark_err: bool) -> Result<(), AppError> { 19 | let mut buf = BufReader::new(read); 20 | loop { 21 | let mut line = String::new(); 22 | let read: usize = buf.read_line(&mut line)?; 23 | if read == 0 { 24 | break; 25 | } 26 | if mark_err { 27 | let prefix = format!("{:>21.21} |", prefix); 28 | if atty { 29 | print!("{} {} {}", "ERR".red(), prefix.fg(color), line); 30 | } else { 31 | print!("ERR {} {}", prefix, line); 32 | }; 33 | } else { 34 | let prefix = format!("{:>25.25} |", prefix); 35 | if atty { 36 | print!("{} {}", prefix.fg(color), line); 37 | } else { 38 | print!("{} {}", prefix, line); 39 | }; 40 | } 41 | } 42 | Ok(()) 43 | } 44 | 45 | fn is_stdout_a_tty() -> bool { 46 | std::io::stdout().is_terminal() 47 | } 48 | 49 | fn is_stderr_a_tty() -> bool { 50 | std::io::stderr().is_terminal() 51 | } 52 | 53 | pub fn spawn_maybe(shell: &[String], cmd: &str, workdir: &Path, project_name: &str, color: Color) -> Result<(), AppError> { 54 | let program: &str = shell 55 | .first() 56 | .ok_or_else(|| AppError::UserError("shell entry in project settings must have at least one element".to_owned()))?; 57 | let rest: &[String] = shell.split_at(1).1; 58 | let mut result: Child = Command::new(program) 59 | .args(rest) 60 | .arg(cmd) 61 | .current_dir(workdir) 62 | .env("FW_PROJECT", project_name) 63 | .stdout(Stdio::piped()) 64 | .stderr(Stdio::piped()) 65 | .stdin(Stdio::null()) 66 | .spawn()?; 67 | 68 | let stdout_child = match result.stdout.take() { 69 | Some(stdout) => { 70 | let project_name = project_name.to_owned(); 71 | Some(thread::spawn(move || { 72 | let atty: bool = is_stdout_a_tty(); 73 | forward_process_output_to_stdout(stdout, &project_name, color, atty, false) 74 | })) 75 | } 76 | _ => None, 77 | }; 78 | 79 | // stream stderr in this thread. no need to spawn another one. 80 | if let Some(stderr) = result.stderr.take() { 81 | let atty: bool = is_stderr_a_tty(); 82 | forward_process_output_to_stdout(stderr, project_name, color, atty, true)? 83 | } 84 | 85 | if let Some(child) = stdout_child { 86 | child.join().expect("Must be able to join child")?; 87 | } 88 | 89 | let status = result.wait()?; 90 | if status.code().unwrap_or(0) > 0 { 91 | Err(AppError::UserError("External command failed.".to_owned())) 92 | } else { 93 | Ok(()) 94 | } 95 | } 96 | 97 | pub fn init_threads(parallel_raw: &Option) -> Result<(), AppError> { 98 | if let Some(ref raw_num) = *parallel_raw { 99 | let num_threads = raw_num.parse::()?; 100 | rayon::ThreadPoolBuilder::new().num_threads(num_threads).build_global().expect( 101 | "Tried to initialize rayon more than once (this is a software bug on fw side, please file an issue at https://github.com/brocode/fw/issues/new )", 102 | ); 103 | } 104 | Ok(()) 105 | } 106 | 107 | pub fn foreach(maybe_config: Result, cmd: &str, tags: &BTreeSet, parallel_raw: &Option) -> Result<(), AppError> { 108 | let config = maybe_config?; 109 | init_threads(parallel_raw)?; 110 | 111 | let projects: Vec<&Project> = config.projects.values().collect(); 112 | let script_results = projects 113 | .par_iter() 114 | .filter(|p| tags.is_empty() || p.tags.clone().unwrap_or_default().intersection(tags).count() > 0) 115 | .map(|p| { 116 | let shell = config.settings.get_shell_or_default(); 117 | let path = config.actual_path_to_project(p); 118 | spawn_maybe(&shell, cmd, &path, &p.name, random_color()) 119 | }) 120 | .collect::>>(); 121 | 122 | script_results.into_iter().fold(Ok(()), Result::and) 123 | } 124 | -------------------------------------------------------------------------------- /src/sync/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use crate::config::metadata_from_repository::MetadataFromRepository; 3 | use crate::config::{Config, project::Project}; 4 | use crate::errors::AppError; 5 | use std::collections::BTreeSet; 6 | use std::fs::read_to_string; 7 | use std::path::Path; 8 | use std::time::Duration; 9 | 10 | use crate::git::{clone_project, update_project_remotes}; 11 | 12 | use crossbeam::queue::SegQueue; 13 | 14 | use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; 15 | 16 | use std::borrow::ToOwned; 17 | 18 | use std::sync::Arc; 19 | use std::thread; 20 | 21 | #[cfg(unix)] 22 | use std::os::unix::fs::FileTypeExt; 23 | 24 | fn sync_project(config: &Config, project: &Project, only_new: bool, ff_merge: bool) -> Result<(), AppError> { 25 | let path = config.actual_path_to_project(project); 26 | let exists = path.exists(); 27 | let result = if exists { 28 | if only_new { 29 | Ok(()) 30 | } else { 31 | update_project_remotes(project, &path, ff_merge).and_then(|_| synchronize_metadata_if_trusted(project, &path)) 32 | } 33 | } else { 34 | clone_project(config, project, &path).and_then(|_| synchronize_metadata_if_trusted(project, &path)) 35 | }; 36 | result.map_err(|e| AppError::RuntimeError(format!("Failed to sync {}: {}", project.name, e))) 37 | } 38 | 39 | pub fn synchronize_metadata_if_trusted(project: &Project, path: &Path) -> Result<(), AppError> { 40 | if !project.trusted { 41 | Ok(()) 42 | } else { 43 | let metadata_file = path.join("fw.toml"); 44 | 45 | if metadata_file.exists() { 46 | let content = read_to_string(metadata_file)?; 47 | let metadata_from_repository = toml::from_str::(&content)?; 48 | 49 | let new_project = Project { 50 | tags: metadata_from_repository.tags, 51 | ..project.to_owned() 52 | }; 53 | 54 | config::write_project(&new_project) 55 | } else { 56 | Ok(()) 57 | } 58 | } 59 | } 60 | 61 | pub fn synchronize(maybe_config: Result, only_new: bool, ff_merge: bool, tags: &BTreeSet, worker: i32) -> Result<(), AppError> { 62 | eprintln!("Synchronizing everything"); 63 | if !ssh_agent_running() { 64 | eprintln!("SSH Agent not running. Process may hang.") 65 | } 66 | let config = Arc::new(maybe_config?); 67 | 68 | let projects: Vec = config.projects.values().map(ToOwned::to_owned).collect(); 69 | let q: Arc> = Arc::new(SegQueue::new()); 70 | let projects_count = projects.len() as u64; 71 | 72 | projects 73 | .into_iter() 74 | .filter(|p| tags.is_empty() || p.tags.clone().unwrap_or_default().intersection(tags).count() > 0) 75 | .for_each(|p| q.push(p)); 76 | 77 | let spinner_style = ProgressStyle::default_spinner() 78 | .tick_chars("⣾⣽⣻⢿⡿⣟⣯⣷⣿") 79 | .template("{prefix:.bold.dim} {spinner} {wide_msg}") 80 | .map_err(|e| AppError::RuntimeError(format!("Invalid Template: {}", e)))?; 81 | 82 | let m = MultiProgress::new(); 83 | m.set_draw_target(ProgressDrawTarget::stderr()); 84 | 85 | let job_results: Arc>> = Arc::new(SegQueue::new()); 86 | let progress_bars = (1..=worker).map(|i| { 87 | let pb = m.add(ProgressBar::new(projects_count)); 88 | pb.set_style(spinner_style.clone()); 89 | pb.set_prefix(format!("[{: >2}/{}]", i, worker)); 90 | pb.set_message("initializing..."); 91 | pb.tick(); 92 | pb.enable_steady_tick(Duration::from_millis(250)); 93 | pb 94 | }); 95 | let mut thread_handles: Vec> = Vec::new(); 96 | for pb in progress_bars { 97 | let job_q = Arc::clone(&q); 98 | let job_config = Arc::clone(&config); 99 | let job_result_queue = Arc::clone(&job_results); 100 | thread_handles.push(thread::spawn(move || { 101 | let mut job_result: Result<(), AppError> = Result::Ok(()); 102 | loop { 103 | if let Some(project) = job_q.pop() { 104 | pb.set_message(project.name.to_string()); 105 | let sync_result = sync_project(&job_config, &project, only_new, ff_merge); 106 | let msg = match sync_result { 107 | Ok(_) => format!("DONE: {}", project.name), 108 | Err(ref e) => format!("FAILED: {} - {}", project.name, e), 109 | }; 110 | pb.println(&msg); 111 | job_result = job_result.and(sync_result); 112 | } else { 113 | pb.finish_and_clear(); 114 | break; 115 | } 116 | } 117 | job_result_queue.push(job_result); 118 | })); 119 | } 120 | 121 | while let Some(cur_thread) = thread_handles.pop() { 122 | cur_thread.join().unwrap(); 123 | } 124 | 125 | let mut synchronize_result: Result<(), AppError> = Result::Ok(()); 126 | while let Some(result) = job_results.pop() { 127 | synchronize_result = synchronize_result.and(result); 128 | } 129 | 130 | m.clear().unwrap(); 131 | 132 | synchronize_result 133 | } 134 | 135 | fn ssh_agent_running() -> bool { 136 | match std::env::var("SSH_AUTH_SOCK") { 137 | Ok(auth_socket) => is_socket(&auth_socket), 138 | Err(_) => false, 139 | } 140 | } 141 | 142 | #[cfg(unix)] 143 | fn is_socket(path: &str) -> bool { 144 | std::fs::metadata(path).map(|m| m.file_type().is_socket()).unwrap_or(false) 145 | } 146 | 147 | #[cfg(not(unix))] 148 | fn is_socket(_: &str) -> bool { 149 | false 150 | } 151 | -------------------------------------------------------------------------------- /src/tag/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use crate::config::settings::Tag; 3 | use crate::config::{Config, project::Project}; 4 | use crate::errors::AppError; 5 | use crate::spawn::init_threads; 6 | use crate::spawn::spawn_maybe; 7 | use crate::util::random_color; 8 | use rayon::prelude::*; 9 | use std::collections::{BTreeMap, BTreeSet}; 10 | use yansi::Paint; 11 | 12 | pub fn list_tags(maybe_config: Result, maybe_project_name: Option) -> Result<(), AppError> { 13 | let config: Config = maybe_config?; 14 | if let Some(project_name) = maybe_project_name { 15 | list_project_tags(&config, &project_name) 16 | } else { 17 | list_all_tags(config); 18 | Ok(()) 19 | } 20 | } 21 | 22 | pub fn delete_tag(maybe_config: Result, tag_name: &str) -> Result<(), AppError> { 23 | let config: Config = maybe_config?; 24 | let tags: BTreeMap = config.settings.tags.unwrap_or_default(); 25 | 26 | // remove tags from projects 27 | for mut project in config.projects.values().cloned() { 28 | let mut new_tags: BTreeSet = project.tags.clone().unwrap_or_default(); 29 | if new_tags.remove(tag_name) { 30 | project.tags = Some(new_tags); 31 | config::write_project(&project)?; 32 | } 33 | } 34 | 35 | if let Some(tag) = tags.get(tag_name) { 36 | config::delete_tag_config(tag_name, tag) 37 | } else { 38 | Ok(()) 39 | } 40 | } 41 | 42 | fn list_all_tags(config: Config) { 43 | if let Some(tags) = config.settings.tags { 44 | for tag_name in tags.keys() { 45 | println!("{}", tag_name); 46 | } 47 | } 48 | } 49 | 50 | pub fn add_tag(config: &Config, project_name: String, tag_name: String) -> Result<(), AppError> { 51 | if let Some(mut project) = config.projects.get(&project_name).cloned() { 52 | let tags: BTreeMap = config.settings.tags.clone().unwrap_or_default(); 53 | if tags.contains_key(&tag_name) { 54 | let mut new_tags: BTreeSet = project.tags.clone().unwrap_or_default(); 55 | new_tags.insert(tag_name); 56 | project.tags = Some(new_tags); 57 | config::write_project(&project)?; 58 | Ok(()) 59 | } else { 60 | Err(AppError::UserError(format!("Unknown tag {}", tag_name))) 61 | } 62 | } else { 63 | Err(AppError::UserError(format!("Unknown project {}", project_name))) 64 | } 65 | } 66 | 67 | pub fn create_tag( 68 | maybe_config: Result, 69 | tag_name: String, 70 | after_workon: Option, 71 | after_clone: Option, 72 | priority: Option, 73 | tag_workspace: Option, 74 | ) -> Result<(), AppError> { 75 | let config: Config = maybe_config?; 76 | let tags: BTreeMap = config.settings.tags.unwrap_or_default(); 77 | 78 | if tags.contains_key(&tag_name) { 79 | Err(AppError::UserError(format!("Tag {} already exists, not gonna overwrite it for you", tag_name))) 80 | } else { 81 | let new_tag = Tag { 82 | after_clone, 83 | after_workon, 84 | priority, 85 | workspace: tag_workspace, 86 | default: None, 87 | tag_config_path: "default".to_string(), 88 | }; 89 | config::write_tag(&tag_name, &new_tag)?; 90 | Ok(()) 91 | } 92 | } 93 | 94 | pub fn inspect_tag(maybe_config: Result, tag_name: &str) -> Result<(), AppError> { 95 | let config: Config = maybe_config?; 96 | let tags: BTreeMap = config.settings.tags.unwrap_or_default(); 97 | if let Some(tag) = tags.get(tag_name) { 98 | println!("{}", Paint::new(tag_name).bold().underline()); 99 | println!("{:<20}: {}", "config path", tag.tag_config_path); 100 | println!("{:<20}: {}", "after workon", tag.after_workon.clone().unwrap_or_default()); 101 | println!("{:<20}: {}", "after clone", tag.after_clone.clone().unwrap_or_default()); 102 | println!("{:<20}: {}", "priority", tag.priority.map(|n| n.to_string()).unwrap_or_default()); 103 | println!("{:<20}: {}", "workspace", tag.workspace.clone().unwrap_or_default()); 104 | println!("{:<20}: {}", "default", tag.default.map(|n| n.to_string()).unwrap_or_default()); 105 | println!(); 106 | println!("{}", Paint::new("projects".to_string()).bold().underline()); 107 | for project in config.projects.values().cloned() { 108 | if project.tags.unwrap_or_default().contains(tag_name) { 109 | println!("{}", project.name) 110 | } 111 | } 112 | Ok(()) 113 | } else { 114 | Err(AppError::UserError(format!("Unkown tag {}", tag_name))) 115 | } 116 | } 117 | 118 | pub fn remove_tag(maybe_config: Result, project_name: String, tag_name: &str) -> Result<(), AppError> { 119 | let config: Config = maybe_config?; 120 | 121 | if let Some(mut project) = config.projects.get(&project_name).cloned() { 122 | let mut new_tags: BTreeSet = project.tags.clone().unwrap_or_default(); 123 | if new_tags.remove(tag_name) { 124 | project.tags = Some(new_tags); 125 | config::write_project(&project) 126 | } else { 127 | Ok(()) 128 | } 129 | } else { 130 | Err(AppError::UserError(format!("Unknown project {}", project_name))) 131 | } 132 | } 133 | 134 | fn list_project_tags(config: &Config, project_name: &str) -> Result<(), AppError> { 135 | if let Some(project) = config.projects.get(project_name) { 136 | if let Some(tags) = project.clone().tags { 137 | for tag_name in tags { 138 | println!("{}", tag_name); 139 | } 140 | } 141 | Ok(()) 142 | } else { 143 | Err(AppError::UserError(format!("Unknown project {}", project_name))) 144 | } 145 | } 146 | 147 | pub fn autotag(maybe_config: Result, cmd: &str, tag_name: &str, parallel_raw: &Option) -> Result<(), AppError> { 148 | let config = maybe_config?; 149 | 150 | let tags: BTreeMap = config.settings.tags.clone().unwrap_or_default(); 151 | if tags.contains_key(tag_name) { 152 | init_threads(parallel_raw)?; 153 | 154 | let projects: Vec<&Project> = config.projects.values().collect(); 155 | 156 | let script_results = projects 157 | .par_iter() 158 | .map(|p| { 159 | let shell = config.settings.get_shell_or_default(); 160 | let path = &config.actual_path_to_project(p); 161 | spawn_maybe(&shell, cmd, path, &p.name, random_color()) 162 | }) 163 | .collect::>>(); 164 | 165 | // map with projects and filter if result == 0 166 | let filtered_projects: Vec<&Project> = script_results 167 | .into_iter() 168 | .zip(projects) 169 | .filter(|(x, _)| x.is_ok()) 170 | .map(|(_, p)| p) 171 | .collect::>(); 172 | 173 | for project in filtered_projects.iter() { 174 | add_tag(&config, project.name.clone(), tag_name.to_string())?; 175 | } 176 | Ok(()) 177 | } else { 178 | Err(AppError::UserError(format!("Unknown tag {}", tag_name))) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/util/mod.rs: -------------------------------------------------------------------------------- 1 | use yansi::Color; 2 | 3 | use rand::seq::IndexedRandom; 4 | 5 | use std::borrow::ToOwned; 6 | 7 | pub static COLOURS: [Color; 12] = [ 8 | Color::Green, 9 | Color::Cyan, 10 | Color::Blue, 11 | Color::Yellow, 12 | Color::Red, 13 | Color::Rgb(255, 165, 0), 14 | Color::Rgb(255, 99, 71), 15 | Color::Rgb(0, 153, 255), 16 | Color::Rgb(153, 102, 51), 17 | Color::Rgb(102, 153, 0), 18 | Color::Rgb(255, 153, 255), 19 | Color::Magenta, 20 | ]; 21 | 22 | pub fn random_color() -> Color { 23 | let mut rng = rand::rng(); 24 | COLOURS.choose(&mut rng).map(ToOwned::to_owned).unwrap_or(Color::Black) 25 | } 26 | -------------------------------------------------------------------------------- /src/workon/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use crate::config::project::Project; 3 | use crate::errors::AppError; 4 | use crate::spawn::spawn_maybe; 5 | 6 | use std::borrow::ToOwned; 7 | use std::env; 8 | use yansi::Color; 9 | 10 | pub fn gen_reworkon(maybe_config: Result) -> Result<(), AppError> { 11 | let config = maybe_config?; 12 | let project = current_project(&config)?; 13 | r#gen(&project.name, Ok(config), false) 14 | } 15 | 16 | fn current_project(config: &config::Config) -> Result { 17 | let os_current_dir = env::current_dir()?; 18 | let current_dir = os_current_dir.to_string_lossy().into_owned(); 19 | let maybe_match = config 20 | .projects 21 | .values() 22 | .find(|&p| config.actual_path_to_project(p).to_string_lossy().eq(¤t_dir)); 23 | maybe_match 24 | .map(ToOwned::to_owned) 25 | .ok_or_else(|| AppError::UserError(format!("No project matching expanded path {} found in config", current_dir))) 26 | } 27 | 28 | pub fn reworkon(maybe_config: Result) -> Result<(), AppError> { 29 | let config = maybe_config?; 30 | let project = current_project(&config)?; 31 | let path = config.actual_path_to_project(&project); 32 | let mut commands: Vec = vec![format!("cd {}", path.to_string_lossy())]; 33 | commands.extend_from_slice(&config.resolve_after_workon(&project)); 34 | 35 | let shell = config.settings.get_shell_or_default(); 36 | spawn_maybe(&shell, &commands.join(" && "), &path, &project.name, Color::Yellow) 37 | } 38 | 39 | pub fn r#gen(name: &str, maybe_config: Result, quick: bool) -> Result<(), AppError> { 40 | let config = maybe_config?; 41 | let project: &Project = config 42 | .projects 43 | .get(name) 44 | .ok_or_else(|| AppError::UserError(format!("project key {} not found in fw.json", name)))?; 45 | let canonical_project_path = config.actual_path_to_project(project); 46 | let path = canonical_project_path 47 | .to_str() 48 | .ok_or(AppError::InternalError("project path is not valid unicode"))?; 49 | if !canonical_project_path.exists() { 50 | Err(AppError::UserError(format!("project key {} found but path {} does not exist", name, path))) 51 | } else { 52 | let mut commands: Vec = vec![format!("cd '{}'", path)]; 53 | if !quick { 54 | commands.extend_from_slice(&config.resolve_after_workon(project)) 55 | } 56 | println!("{}", commands.join(" && ")); 57 | Ok(()) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/ws/github/mod.rs: -------------------------------------------------------------------------------- 1 | // some of the code is from here: https://github.com/mgattozzi/github-rs/tree/master/github-gql-rs 2 | // this package seems unmaintained at the moment. Also it is basically just a small http client wrapper. 3 | 4 | use crate::errors::AppError; 5 | use serde::de::DeserializeOwned; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | pub fn github_api(token: &str) -> Result { 9 | let client = reqwest::blocking::Client::new(); 10 | Ok(GithubApi { 11 | client, 12 | token: token.to_string(), 13 | }) 14 | } 15 | 16 | pub struct GithubApi { 17 | client: reqwest::blocking::Client, 18 | token: String, 19 | } 20 | 21 | struct PageResult { 22 | repository_names: Vec, 23 | next_cursor: Option, 24 | } 25 | 26 | #[derive(Serialize, Deserialize, Debug)] 27 | struct OrganizationQueryResponse { 28 | data: OrganizationQueryResponseData, 29 | } 30 | 31 | #[derive(Serialize, Deserialize, Debug)] 32 | struct OrganizationQueryResponseData { 33 | organization: OrganizationRepositoriesResponseData, 34 | } 35 | 36 | #[derive(Serialize, Deserialize, Debug)] 37 | struct OrganizationRepositoriesResponseData { 38 | repositories: RepositoriesResponseData, 39 | } 40 | 41 | #[derive(Serialize, Deserialize, Debug)] 42 | struct RepositoriesResponseData { 43 | nodes: Vec, 44 | #[serde(rename = "pageInfo")] 45 | page_info: PageInfo, 46 | } 47 | 48 | #[derive(Serialize, Deserialize, Debug)] 49 | struct Repository { 50 | name: String, 51 | #[serde(rename = "isArchived")] 52 | is_archived: bool, 53 | } 54 | 55 | #[derive(Serialize, Deserialize, Debug)] 56 | struct PageInfo { 57 | #[serde(rename = "endCursor")] 58 | end_cursor: String, 59 | #[serde(rename = "hasNextPage")] 60 | has_next_page: bool, 61 | } 62 | 63 | impl GithubApi { 64 | fn query(&self, query: &str) -> Result { 65 | //escaping new lines and quotation marks for json 66 | let mut escaped = query.to_string(); 67 | escaped = escaped.replace('\n', "\\n"); 68 | escaped = escaped.replace('\"', "\\\""); 69 | 70 | let mut q = String::from("{ \"query\": \""); 71 | q.push_str(&escaped); 72 | q.push_str("\" }"); 73 | 74 | let res = self 75 | .client 76 | .post("https://api.github.com/graphql") 77 | .body(reqwest::blocking::Body::from(q)) 78 | .header("Content-Type", "application/json") 79 | .header("User-Agent", "github-rs") 80 | .header("Authorization", format!("token {}", self.token)) 81 | .send()?; 82 | 83 | if res.status().is_success() { 84 | res.json::().map_err(|e| AppError::RuntimeError(format!("Failed to parse response: {}", e))) 85 | } else { 86 | Err(AppError::RuntimeError(format!("Bad status from github {}", res.status()))) 87 | } 88 | } 89 | 90 | pub fn list_repositories(&mut self, org: &str, include_archived: bool) -> Result, AppError> { 91 | let initial_page = self.page_repositories(org, None, include_archived)?; 92 | let mut initial_names = initial_page.repository_names; 93 | 94 | let mut next: Option = initial_page.next_cursor; 95 | while next.is_some() { 96 | let next_repos = self.page_repositories(org, next.clone(), include_archived)?; 97 | initial_names.extend(next_repos.repository_names); 98 | next = next_repos.next_cursor; 99 | } 100 | 101 | Ok(initial_names) 102 | } 103 | fn page_repositories(&mut self, org: &str, after: Option, include_archived: bool) -> Result { 104 | let after_refinement = after.map(|a| format!(", after:\"{}\"", a)).unwrap_or_else(|| "".to_owned()); 105 | let response: OrganizationQueryResponse = self.query( 106 | &("query {organization(login: \"".to_owned() 107 | + org 108 | + "\"){repositories(first: 100" 109 | + &after_refinement 110 | + ") {nodes {name, isArchived} pageInfo {endCursor hasNextPage}}}}"), 111 | )?; 112 | let repositories: Vec = response.data.organization.repositories.nodes; 113 | let repo_names: Vec = repositories 114 | .into_iter() 115 | .filter(|r| include_archived || !r.is_archived) 116 | .map(|r| r.name) 117 | .collect(); 118 | 119 | Ok(PageResult { 120 | repository_names: repo_names, 121 | next_cursor: if response.data.organization.repositories.page_info.has_next_page { 122 | Some(response.data.organization.repositories.page_info.end_cursor) 123 | } else { 124 | None 125 | }, 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/ws/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod github; 2 | --------------------------------------------------------------------------------