Repository: alajmo/mani Branch: main Commit: cce639f8e72d Files: 330 Total size: 635.9 KB Directory structure: gitextract_1sykag_x/ ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── BUG_REPORT.md │ │ └── FEATURE_REQUEST.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── agents/ │ │ └── default.agent.md │ ├── copilot-instructions.md │ └── workflows/ │ ├── release.yml │ └── test.yml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── CLAUDE.md ├── LICENSE ├── Makefile ├── README.md ├── benchmarks/ │ └── .gitignore ├── cmd/ │ ├── check.go │ ├── completion.go │ ├── describe.go │ ├── describe_projects.go │ ├── describe_tasks.go │ ├── edit.go │ ├── edit_project.go │ ├── edit_task.go │ ├── exec.go │ ├── gen.go │ ├── gen_docs.go │ ├── init.go │ ├── list.go │ ├── list_projects.go │ ├── list_tags.go │ ├── list_tasks.go │ ├── root.go │ ├── run.go │ ├── sync.go │ └── tui.go ├── core/ │ ├── config.man │ ├── dao/ │ │ ├── benchmark_test.go │ │ ├── common.go │ │ ├── common_test.go │ │ ├── config.go │ │ ├── config_test.go │ │ ├── import.go │ │ ├── project.go │ │ ├── project_test.go │ │ ├── spec.go │ │ ├── spec_test.go │ │ ├── tag.go │ │ ├── tag_expr.go │ │ ├── tag_expr_test.go │ │ ├── target.go │ │ ├── target_test.go │ │ ├── task.go │ │ ├── task_test.go │ │ ├── theme.go │ │ ├── theme_block.go │ │ ├── theme_stream.go │ │ ├── theme_table.go │ │ ├── theme_tree.go │ │ ├── theme_tui.go │ │ └── utils_test.go │ ├── errors.go │ ├── exec/ │ │ ├── client.go │ │ ├── clone.go │ │ ├── exec.go │ │ ├── table.go │ │ ├── text.go │ │ ├── unix.go │ │ └── windows.go │ ├── flags.go │ ├── man.go │ ├── man_gen.go │ ├── mani.1 │ ├── prefixer.go │ ├── prefixer_benchmark_test.go │ ├── print/ │ │ ├── lib.go │ │ ├── print_block.go │ │ ├── print_table.go │ │ ├── print_tree.go │ │ └── table.go │ ├── sizedwaitgroup.go │ ├── tui/ │ │ ├── components/ │ │ │ ├── tui_button.go │ │ │ ├── tui_checkbox.go │ │ │ ├── tui_filter.go │ │ │ ├── tui_list.go │ │ │ ├── tui_modal.go │ │ │ ├── tui_output.go │ │ │ ├── tui_search.go │ │ │ ├── tui_table.go │ │ │ ├── tui_text.go │ │ │ ├── tui_textarea.go │ │ │ ├── tui_toggle_text.go │ │ │ └── tui_tree.go │ │ ├── misc/ │ │ │ ├── tui_event.go │ │ │ ├── tui_focus.go │ │ │ ├── tui_global.go │ │ │ ├── tui_theme.go │ │ │ ├── tui_utils.go │ │ │ └── tui_writer.go │ │ ├── pages/ │ │ │ ├── tui_exec.go │ │ │ ├── tui_project.go │ │ │ ├── tui_run.go │ │ │ └── tui_task.go │ │ ├── pages.go │ │ ├── tui.go │ │ ├── tui_input.go │ │ ├── views/ │ │ │ ├── tui_help.go │ │ │ ├── tui_project_view.go │ │ │ ├── tui_shortcut_info.go │ │ │ ├── tui_spec_view.go │ │ │ └── tui_task_view.go │ │ └── watcher.go │ └── utils.go ├── docs/ │ ├── changelog.md │ ├── commands.md │ ├── config.md │ ├── contributing.md │ ├── development.md │ ├── error-handling.md │ ├── examples.md │ ├── filtering-projects.md │ ├── installation.md │ ├── introduction.md │ ├── man-pages.md │ ├── output.md │ ├── project-background.md │ ├── roadmap.md │ ├── shell-completion.mdx │ ├── usage.md │ └── variables.md ├── examples/ │ ├── .gitignore │ ├── README.md │ └── mani.yaml ├── go.mod ├── go.sum ├── install.sh ├── main.go ├── res/ │ ├── demo.md │ ├── demo.vhs │ └── mani.yaml ├── scripts/ │ └── release.sh └── test/ ├── README.md ├── fixtures/ │ ├── mani-advanced/ │ │ ├── .gitignore │ │ └── mani.yaml │ ├── mani-empty/ │ │ └── mani.yaml │ └── mani-no-tasks/ │ └── mani.yaml ├── images/ │ ├── alpine.exec.Dockerfile │ └── alpine.test.Dockerfile ├── integration/ │ ├── describe_test.go │ ├── exec_test.go │ ├── golden/ │ │ ├── describe/ │ │ │ ├── golden-0/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-1/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-2/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-3/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-4/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-5/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-6/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-7/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-8/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ └── golden-9/ │ │ │ ├── mani.yaml │ │ │ └── stdout.golden │ │ ├── exec/ │ │ │ ├── golden-0/ │ │ │ │ └── stdout.golden │ │ │ ├── golden-1/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-2/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-3/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-4/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-5/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ └── golden-6/ │ │ │ ├── .gitignore │ │ │ ├── frontend/ │ │ │ │ ├── dashgrid/ │ │ │ │ │ └── empty │ │ │ │ └── pinto/ │ │ │ │ └── empty │ │ │ ├── mani.yaml │ │ │ └── stdout.golden │ │ ├── init/ │ │ │ ├── golden-0/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-1/ │ │ │ │ ├── .gitignore │ │ │ │ ├── dashgrid/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ ├── nameless/ │ │ │ │ │ └── empty │ │ │ │ ├── nested/ │ │ │ │ │ └── template-generator/ │ │ │ │ │ └── empty │ │ │ │ └── stdout.golden │ │ │ └── golden-2/ │ │ │ ├── mani.yaml │ │ │ └── stdout.golden │ │ ├── list/ │ │ │ ├── golden-0/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-1/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-10/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-11/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-12/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-13/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-14/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-15/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-16/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-17/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-2/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-3/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-4/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-5/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-6/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-7/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-8/ │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ └── golden-9/ │ │ │ ├── mani.yaml │ │ │ └── stdout.golden │ │ ├── run/ │ │ │ ├── golden-0/ │ │ │ │ └── stdout.golden │ │ │ ├── golden-1/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-10/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-11/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-2/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-3/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-4/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-5/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-6/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-7/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ ├── golden-8/ │ │ │ │ ├── .gitignore │ │ │ │ ├── frontend/ │ │ │ │ │ ├── dashgrid/ │ │ │ │ │ │ └── empty │ │ │ │ │ └── pinto/ │ │ │ │ │ └── empty │ │ │ │ ├── mani.yaml │ │ │ │ └── stdout.golden │ │ │ └── golden-9/ │ │ │ ├── .gitignore │ │ │ ├── frontend/ │ │ │ │ ├── dashgrid/ │ │ │ │ │ └── empty │ │ │ │ └── pinto/ │ │ │ │ └── empty │ │ │ ├── mani.yaml │ │ │ └── stdout.golden │ │ ├── sync/ │ │ │ ├── golden-0/ │ │ │ │ └── stdout.golden │ │ │ └── golden-1/ │ │ │ ├── .gitignore │ │ │ ├── frontend/ │ │ │ │ ├── dashgrid/ │ │ │ │ │ └── empty │ │ │ │ └── pinto/ │ │ │ │ └── empty │ │ │ ├── mani.yaml │ │ │ └── stdout.golden │ │ └── version/ │ │ ├── golden-0/ │ │ │ └── stdout.golden │ │ └── golden-1/ │ │ ├── mani.yaml │ │ └── stdout.golden │ ├── init_test.go │ ├── list_test.go │ ├── main_test.go │ ├── run_test.go │ ├── sync_test.go │ └── version_test.go ├── playground/ │ ├── .gitignore │ ├── imports/ │ │ ├── many-projects.yaml │ │ ├── projects.yaml │ │ ├── specs.yaml │ │ ├── targets.yaml │ │ ├── tasks.yaml │ │ └── themes.yaml │ └── mani.yaml └── scripts/ ├── exec ├── git └── test ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git ================================================ FILE: .github/FUNDING.yml ================================================ custom: ["https://paypal.me/samiralajmovic", "https://www.buymeacoffee.com/alajmo"] ================================================ FILE: .github/ISSUE_TEMPLATE/BUG_REPORT.md ================================================ --- name: Bug report about: Report a bug title: '' labels: 'bug' assignees: '' --- - [ ] I have the latest version of mani - [ ] I have searched through the existing issues ## Info - OS - [ ] Linux - [ ] Mac OS X - [ ] Windows - [ ] other - Shell - [ ] Bash - [ ] Zsh - [ ] Fish - [ ] Powershell - [ ] other - Version: ## Problem / Steps to reproduce ================================================ FILE: .github/ISSUE_TEMPLATE/FEATURE_REQUEST.md ================================================ --- name: Feature request about: Suggest an feature for this project title: '' labels: 'enhancement' assignees: '' --- ## Is your feature request related to a problem? Please describe ## Describe the solution you'd like ## Additional context ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ### What's Changed A description of the issue/feature; reference the issue number (if one exists). The Pull Request should not include fixes for issues other than the main issue/feature request. ### Technical Description Any specific technical detail that may provide additional context to aid code review. > Before opening a Pull Request you should read and agreed to the Contributor Code of Conduct (see `CONTRIBUTING.md`\) ================================================ FILE: .github/agents/default.agent.md ================================================ --- name: default description: add features and fix bugs --- # Copilot Instructions for mani This document provides guidance for GitHub Copilot when working on the `mani` repository. `mani` is a CLI tool written in Go that helps manage multiple repositories. It's useful for microservices, multi-project systems, or any collection of repositories. ## Project Overview - **Language**: Go 1.25+ - **CLI Framework**: [Cobra](https://github.com/spf13/cobra) - **Configuration**: YAML-based configuration files (`mani.yaml`) - **Key Features**: Repository management, task running across multiple repos, TUI interface ## Repository Structure ``` mani/ ├── cmd/ # CLI commands (Cobra commands) ├── core/ # Core business logic │ ├── dao/ # Data access objects, config parsing │ ├── exec/ # Command execution logic │ ├── print/ # Output formatting │ └── tui/ # Terminal UI components ├── docs/ # Documentation ├── examples/ # Example configurations ├── test/ # Test fixtures and integration tests │ ├── fixtures/ # Test data │ ├── integration/ # Integration test files │ └── scripts/ # Test scripts └── main.go # Entry point ``` ## Development Commands Build commands can be found in Makefile. ## Coding Standards ### Go Code Style 1. **Follow standard Go conventions**: - Use `gofmt` for formatting - Follow [Effective Go](https://go.dev/doc/effective_go) guidelines - Use meaningful variable and function names 2. **Error handling**: - Always check and handle errors explicitly - Use the custom error types in `core/errors.go` for consistent error messages - Return errors to callers rather than panicking 3. **Package structure**: - Keep `cmd/` for CLI command definitions only - Business logic goes in `core/` - Data structures and parsing go in `core/dao/` ## Adding Features When adding a new feature: 1. **Understand the existing patterns**: - Review similar features in the codebase - Follow the established code organization 2. **Implementation steps**: - Add data structures in `core/dao/` if needed - Implement business logic in `core/` - Add CLI command in `cmd/` if needed - Add tests (unit and/or integration) 3. **Configuration changes**: - Update `mani.yaml` schema if adding new config options - Update documentation in `docs/config.md` - Consider backward compatibility 4. **Documentation**: - Update relevant docs in `docs/` - Update README.md if needed - Add examples if appropriate ## Fixing Bugs When fixing a bug: 1. **Reproduce the issue**: - Create a minimal test case - Understand the root cause 2. **Fix process**: - Make the minimal change needed - Add a test that would have caught the bug - Verify the fix doesn't break existing functionality 3. **Verification**: - Run `make test-unit` to ensure all tests pass - Run `make lint` to check code quality - Test manually with real scenarios ## Platform Considerations - **Windows support**: The code handles Windows-specific shell behavior - Default shell on Windows is `powershell -NoProfile` - Default shell on Unix is `bash -c` - **Cross-platform paths**: Use `filepath.Join()` for paths ## Key Dependencies - `github.com/spf13/cobra` - CLI framework - `gopkg.in/yaml.v3` - YAML parsing - `github.com/gdamore/tcell/v2` - Terminal handling for TUI - `github.com/rivo/tview` - TUI components - `github.com/jedib0t/go-pretty/v6` - Table formatting ## Configuration File Structure The main configuration file is `mani.yaml`: ```yaml # Global settings shell: bash -c sync_remotes: false sync_gitignore: true # Environment variables env: KEY: value # Project definitions projects: project-name: path: ./relative/path url: git@github.com:user/repo.git tags: [tag1, tag2] # Task definitions tasks: task-name: desc: Task description cmd: echo "command" # Or multi-command: commands: - task: other-task - cmd: echo "inline command" ``` ## Pull Request Guidelines 1. Reference the issue number if one exists 2. Provide a clear description of changes 3. Ensure all tests pass (`make test`) 4. Ensure code is formatted (`make gofmt`) 5. Ensure linter passes (`make lint`) 6. Update documentation if needed ================================================ FILE: .github/copilot-instructions.md ================================================ # Copilot Instructions for mani This document provides guidance for GitHub Copilot when working on the `mani` repository. `mani` is a CLI tool written in Go that helps manage multiple repositories. It's useful for microservices, multi-project systems, or any collection of repositories. ## Project Overview - **Language**: Go 1.25+ - **CLI Framework**: [Cobra](https://github.com/spf13/cobra) - **Configuration**: YAML-based configuration files (`mani.yaml`) - **Key Features**: Repository management, task running across multiple repos, TUI interface ## Repository Structure ``` mani/ ├── cmd/ # CLI commands (Cobra commands) ├── core/ # Core business logic │ ├── dao/ # Data access objects, config parsing │ ├── exec/ # Command execution logic │ ├── print/ # Output formatting │ └── tui/ # Terminal UI components ├── docs/ # Documentation ├── examples/ # Example configurations ├── test/ # Test fixtures and integration tests │ ├── fixtures/ # Test data │ ├── integration/ # Integration test files │ └── scripts/ # Test scripts └── main.go # Entry point ``` ## Development Commands Build commands can be found in Makefile. ## Coding Standards ### Go Code Style 1. **Follow standard Go conventions**: - Use `gofmt` for formatting - Follow [Effective Go](https://go.dev/doc/effective_go) guidelines - Use meaningful variable and function names 2. **Error handling**: - Always check and handle errors explicitly - Use the custom error types in `core/errors.go` for consistent error messages - Return errors to callers rather than panicking 3. **Package structure**: - Keep `cmd/` for CLI command definitions only - Business logic goes in `core/` - Data structures and parsing go in `core/dao/` ## Adding Features When adding a new feature: 1. **Understand the existing patterns**: - Review similar features in the codebase - Follow the established code organization 2. **Implementation steps**: - Add data structures in `core/dao/` if needed - Implement business logic in `core/` - Add CLI command in `cmd/` if needed - Add tests (unit and/or integration) 3. **Configuration changes**: - Update `mani.yaml` schema if adding new config options - Update documentation in `docs/config.md` - Consider backward compatibility 4. **Documentation**: - Update relevant docs in `docs/` - Update README.md if needed - Add examples if appropriate ## Fixing Bugs When fixing a bug: 1. **Reproduce the issue**: - Create a minimal test case - Understand the root cause 2. **Fix process**: - Make the minimal change needed - Add a test that would have caught the bug - Verify the fix doesn't break existing functionality 3. **Verification**: - Run `make test-unit` to ensure all tests pass - Run `make lint` to check code quality - Test manually with real scenarios ## Platform Considerations - **Windows support**: The code handles Windows-specific shell behavior - Default shell on Windows is `powershell -NoProfile` - Default shell on Unix is `bash -c` - **Cross-platform paths**: Use `filepath.Join()` for paths ## Key Dependencies - `github.com/spf13/cobra` - CLI framework - `gopkg.in/yaml.v3` - YAML parsing - `github.com/gdamore/tcell/v2` - Terminal handling for TUI - `github.com/rivo/tview` - TUI components - `github.com/jedib0t/go-pretty/v6` - Table formatting ## Configuration File Structure The main configuration file is `mani.yaml`: ```yaml # Global settings shell: bash -c sync_remotes: false sync_gitignore: true # Environment variables env: KEY: value # Project definitions projects: project-name: path: ./relative/path url: git@github.com:user/repo.git tags: [tag1, tag2] # Task definitions tasks: task-name: desc: Task description cmd: echo "command" # Or multi-command: commands: - task: other-task - cmd: echo "inline command" ``` ## Pull Request Guidelines 1. Reference the issue number if one exists 2. Provide a clear description of changes 3. Ensure all tests pass (`make test`) 4. Ensure code is formatted (`make gofmt`) 5. Ensure linter passes (`make lint`) 6. Update documentation if needed ================================================ FILE: .github/workflows/release.yml ================================================ name: release on: push: tags: - "v*" jobs: release: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: "1.25.5" - name: Create release notes run: ./scripts/release.sh - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser version: latest args: release --release-notes=release-changelog.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.GH_PAT }} ================================================ FILE: .github/workflows/test.yml ================================================ name: test on: pull_request: branches: [main] paths-ignore: - "**.md" jobs: test: name: test runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] go: ["1.25.5"] steps: - name: Checkout uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: "1.25.5" - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: version: v2.7.1 - name: Get dependencies run: go get -v -t -d ./... - name: Test run: make test - name: Build run: make build ================================================ FILE: .gitignore ================================================ # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependency directories (remove the comment below to include it) vendor/ dist target test/tmp examples/frontend release-changelog.md .netlify .tags assets .config res/go ================================================ FILE: .golangci.yaml ================================================ version: "2" linters: settings: errcheck: exclude-functions: - fmt.Fprintf - fmt.Fprint - (io.Closer).Close ================================================ FILE: .goreleaser.yaml ================================================ version: 2 project_name: mani before: hooks: - go mod download builds: - binary: mani id: mani ldflags: -s -w -X github.com/alajmo/mani/cmd.version={{ .Version }} -X github.com/alajmo/mani/cmd.commit={{ .ShortCommit }} -X github.com/alajmo/mani/cmd.date={{ .Date }} env: - CGO_ENABLED=0 goos: - darwin - linux - windows - freebsd - netbsd - openbsd goarch: - amd64 - '386' - arm - arm64 goarm: - '7' ignore: - goos: freebsd goarch: arm - goos: freebsd goarch: arm64 - goos: openbsd goarch: arm - goos: openbsd goarch: arm64 - goos: darwin goarch: arm - goos: darwin goarch: '386' - goos: windows goarch: arm - goos: windows goarch: arm64 archives: - id: 'mani' builds: ['mani'] format: tar.gz format_overrides: - goos: windows format: zip name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' files: - LICENSE - src: 'core/mani.1' dst: '.' strip_parent: true brews: - name: mani description: 'CLI tool to help you manage multiple repositories' homepage: 'https://manicli.com' license: 'MIT' repository: owner: alajmo name: homebrew-mani token: '{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}' directory: Formula checksum: name_template: 'checksums.txt' snapshot: version_template: '{{ .Tag }}' ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview mani is a CLI tool written in Go that helps manage multiple repositories. It's useful for microservices, multi-project systems, or any collection of repositories where you need a central place to pull repositories and run commands across them. ## Build & Development Commands ```bash make build # Build for current platform (output: dist/mani) make build-all # Cross-platform builds using goreleaser make test # Run unit + integration tests make test-unit # Run unit tests only (go test ./core/dao/...) make test-integration # Run integration tests (requires Docker) make update-golden-files # Update golden test outputs make gofmt # Format Go code make lint # Run golangci-lint and deadcode make tidy # Update dependencies ``` ## Architecture **Entry point flow:** ``` main.go -> cmd.Execute() (cmd/root.go) -> Cobra command routing ``` **Core layers:** - `cmd/` - Cobra CLI command definitions (exec, run, sync, list, describe, tui, etc.) - `core/dao/` - Data structures, YAML config parsing, project/task/spec definitions - `core/exec/` - Task execution engine, SSH/exec clients, cloning logic - `core/print/` - Output formatters (table, stream, tree, html, markdown) - `core/tui/` - Terminal UI using tview/tcell **Configuration:** YAML-based (`mani.yaml`) with projects, tasks, specs, and themes. ## Key Patterns - CLI commands in `cmd/` delegate to business logic in `core/` - Custom error types in `core/errors.go` - use `core.CheckIfError()` for consistency - Config lookup: `mani.yaml`, `mani.yml`, `.mani.yaml`, `.mani.yml` - Shell handling: bash on Unix, powershell on Windows - Use `filepath.Join()` for cross-platform paths ## Testing - Unit tests in `core/dao/*_test.go` - Integration tests in `test/integration/` using Docker and golden files - Golden files in `test/integration/golden/` serve as expected outputs - Test script: `./test/scripts/test` (supports --debug, --build, --run, --update, --count, --clean) ## Adding Features 1. Add data structures in `core/dao/` if needed 2. Implement business logic in `core/` 3. Add CLI command in `cmd/` if needed 4. Update config schema in `docs/config.md` if adding new options 5. Add tests (unit and/or integration) ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2020-2021 Samir Alajmovic Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ NAME := mani PACKAGE := github.com/alajmo/$(NAME) DATE := $(shell date +"%Y %B %d") GIT := $(shell [ -d .git ] && git rev-parse --short HEAD) VERSION := v0.31.2 default: build tidy: go get -u && go mod tidy gofmt: go fmt ./cmd/***.go go fmt ./core/***.go go fmt ./core/dao/***.go go fmt ./core/exec/***.go go fmt ./core/print/***.go go fmt ./core/tui/***.go go fmt ./test/integration/***.go lint: golangci-lint run ./... deadcode . test: # Unit tests go test -v ./core/dao/*** # Integration tests ./test/scripts/test --build --count 5 --clean test-unit: go test -v ./core/dao/*** bench: go test -bench=. -benchmem ./core/dao/... ./core/... bench-save: @mkdir -p benchmarks go test -bench=. -benchmem -count=6 ./core/dao/... ./core/... > benchmarks/bench-$(shell date +%Y%m%d-%H%M%S).txt 2>&1 bench-compare: @if [ -n "$(OLD)" ] && [ -n "$(NEW)" ]; then benchstat $(OLD) $(NEW); fi test-integration: ./test/scripts/test --count 5 --build --clean update-golden-files: ./test/scripts/test --build --update build: CGO_ENABLED=0 go build \ -ldflags "-w -X '${PACKAGE}/cmd.version=${VERSION}' -X '${PACKAGE}/core/tui.version=${VERSION}' -X '${PACKAGE}/cmd.commit=${GIT}' -X '${PACKAGE}/cmd.date=${DATE}'" \ -a -tags netgo -o dist/${NAME} main.go build-all: goreleaser release --snapshot --clean build-test: CGO_ENABLED=0 go build \ -ldflags "-X '${PACKAGE}/core/dao.build_mode=TEST'" \ -a -tags netgo -o dist/${NAME} main.go gen-man: go run -ldflags="-X 'github.com/alajmo/mani/cmd.buildMode=man' -X '${PACKAGE}/cmd.version=${VERSION}' -X '${PACKAGE}/cmd.commit=${GIT}' -X '${PACKAGE}/cmd.date=${DATE}'" ./main.go gen-docs release: git tag ${VERSION} && git push origin ${VERSION} clean: $(RM) -r dist target .PHONY: tidy gofmt lint test test-unit test-integration update-golden-files build build-all build-test gen-man release clean bench bench-save bench-compare ================================================ FILE: README.md ================================================

mani

version build status license Go Report Card reference

`mani` lets you manage multiple repositories and run commands across them. ![demo](res/demo.gif) Interested in managing your servers in a similar way? Checkout [sake](https://github.com/alajmo/sake)! ## Table of Contents - [Sponsors](#sponsors) - [Installation](#installation) - [Building From Source](#building-from-source) - [Usage](#usage) - [Initialize Mani](#initialize-mani) - [Example Commands](#example-commands) - [Documentation](#documentation) - [License](#license) ## Sponsors Mani is an MIT-licensed open source project with ongoing development. If you'd like to support their efforts, check out [Tabify](https://chromewebstore.google.com/detail/tabify/bokfkclamoepkmhjncgkdldmhfpgfdmo) - a Chrome extension that enhances your browsing experience with powerful window and tab management, focus-improving site blocking, and numerous features to optimize your browser workflow. ## Installation `mani` is available on Linux and Mac, with partial support for Windows.
Binaries Download from the [release](https://github.com/alajmo/mani/releases) page.
cURL (Linux & macOS) ```sh curl -sfL https://raw.githubusercontent.com/alajmo/mani/main/install.sh | sh ```
Homebrew ```sh brew tap alajmo/mani brew install mani ```
MacPorts ```sh sudo port install mani ```
Arch (AUR) ```sh yay -S mani ```
Nix ```sh nix-env -iA nixos.mani ```
Go ```sh go get -u github.com/alajmo/mani ```
Building From Source 1. Clone the repo 2. Build and run the executable ```sh make build && ./dist/mani ```
Auto-completion is available via `mani completion bash|zsh|fish|powershell` and man page via `mani gen`. ## Usage ### Initialize Mani Run the following command inside a directory containing your `git` repositories: ```sh mani init ``` This will generate: - `mani.yaml`: Contains projects and custom tasks. Any subdirectory that has a `.git` directory will be included (add the flag `--auto-discovery=false` to turn off this feature) - `.gitignore`: (only when inside a git repo) Includes the projects specified in `mani.yaml` file. To opt out, use `mani init --sync-gitignore=false`. It can be helpful to initialize the `mani` repository as a git repository so that anyone can easily download the `mani` repository and run `mani sync` to clone all repositories and get the same project setup as you. ### Example Commands ```bash # List all projects mani list projects # Run git status across all projects mani exec --all git status # Run git status across all projects in parallel with output in table format mani exec --all --parallel --output table git status ``` ### Documentation Checkout the following to learn more about mani: - [Examples](examples) - [Config](docs/config.md) - [Commands](docs/commands.md) - Documentation - [Filtering Projects](docs/filtering-projects.md) - [Variables](docs/variables.md) - [Output](docs/output.md) - [Changelog](/docs/changelog.md) - [Roadmap](/docs/roadmap.md) - [Project Background](docs/project-background.md) - [Contributing](docs/contributing.md) ## [License](LICENSE) The MIT License (MIT) Copyright (c) 2020-2021 Samir Alajmovic ================================================ FILE: benchmarks/.gitignore ================================================ # Ignore benchmark output files (they can be large and change frequently) # Use `make bench-save` to generate new results *.txt ================================================ FILE: cmd/check.go ================================================ package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/alajmo/mani/core" ) func checkCmd(configErr *error) *cobra.Command { cmd := cobra.Command{ Use: "check", Short: "Validate config", Long: `Validate config.`, Example: ` # Validate config mani check`, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { if *configErr != nil { fmt.Printf("Found configuration errors:\n\n") core.Exit(*configErr) } fmt.Println("Config Valid") }, DisableAutoGenTag: true, } return &cmd } ================================================ FILE: cmd/completion.go ================================================ package cmd import ( "os" "github.com/spf13/cobra" "github.com/alajmo/mani/core" ) func completionCmd() *cobra.Command { cmd := cobra.Command{ Use: "completion ", Short: "Generate completion script", Long: `To load completions: Bash: $ source <(mani completion bash) # To load completions for each session, execute once: # Linux: $ mani completion bash > /etc/bash_completion.d/mani # macOS: $ mani completion bash > /usr/local/etc/bash_completion.d/mani Zsh: # If shell completion is not already enabled in your environment, # you will need to enable it. You can execute the following once: $ echo "autoload -U compinit; compinit" >> ~/.zshrc # To load completions for each session, execute once: $ mani completion zsh > "${fpath[1]}/_mani" # You will need to start a new shell for this setup to take effect. fish: $ mani completion fish | source # To load completions for each session, execute once: $ mani completion fish > ~/.config/fish/completions/mani.fish PowerShell: PS> mani completion powershell | Out-String | Invoke-Expression # To load completions for every new session, run: PS> mani completion powershell > mani.ps1 # and source this file from your PowerShell profile. `, DisableFlagsInUseLine: true, ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), Run: generateCompletion, DisableAutoGenTag: true, } return &cmd } func generateCompletion(cmd *cobra.Command, args []string) { switch args[0] { case "bash": err := cmd.Root().GenBashCompletion(os.Stdout) core.CheckIfError(err) case "zsh": err := cmd.Root().GenZshCompletion(os.Stdout) core.CheckIfError(err) case "fish": err := cmd.Root().GenFishCompletion(os.Stdout, true) core.CheckIfError(err) case "powershell": err := cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) core.CheckIfError(err) } } ================================================ FILE: cmd/describe.go ================================================ package cmd import ( "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" ) func describeCmd(config *dao.Config, configErr *error) *cobra.Command { var describeFlags core.DescribeFlags cmd := cobra.Command{ Aliases: []string{"desc"}, Use: "describe", Short: "Describe projects and tasks", Long: "Describe projects and tasks.", Example: ` # Describe all projects mani describe projects # Describe all tasks mani describe tasks`, DisableAutoGenTag: true, } cmd.AddCommand( describeProjectsCmd(config, configErr, &describeFlags), describeTasksCmd(config, configErr, &describeFlags), ) cmd.PersistentFlags().StringVar(&describeFlags.Theme, "theme", "default", "set theme") err := cmd.RegisterFlagCompletionFunc("theme", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } names := config.GetThemeNames() return names, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) return &cmd } ================================================ FILE: cmd/describe_projects.go ================================================ package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/print" ) func describeProjectsCmd( config *dao.Config, configErr *error, describeFlags *core.DescribeFlags, ) *cobra.Command { var projectFlags core.ProjectFlags var setProjectFlags core.SetProjectFlags cmd := cobra.Command{ Aliases: []string{"project", "prj"}, Use: "projects [projects]", Short: "Describe projects", Long: "Describe projects.", Example: ` # Describe all projects mani describe projects # Describe projects by name mani describe projects # Describe projects by tags mani describe projects --tags # Describe projects by paths mani describe projects --paths # Describe projects matching a tag expression mani run --tags-expr ' || '`, Run: func(cmd *cobra.Command, args []string) { core.CheckIfError(*configErr) setProjectFlags.All = cmd.Flags().Changed("all") setProjectFlags.Cwd = cmd.Flags().Changed("cwd") setProjectFlags.Target = cmd.Flags().Changed("target") describeProjects(config, args, &projectFlags, &setProjectFlags, describeFlags) }, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } projectNames := config.GetProjectNames() return projectNames, cobra.ShellCompDirectiveNoFileComp }, DisableAutoGenTag: true, } cmd.Flags().BoolVarP(&projectFlags.All, "all", "a", true, "select all projects") cmd.Flags().BoolVarP(&projectFlags.Cwd, "cwd", "k", false, "select current working directory") cmd.Flags().StringSliceVarP(&projectFlags.Tags, "tags", "t", []string{}, "filter projects by tags") err := cmd.RegisterFlagCompletionFunc("tags", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } options := config.GetTags() return options, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringVarP(&projectFlags.TagsExpr, "tags-expr", "E", "", "target projects by tags expression") core.CheckIfError(err) cmd.Flags().StringSliceVarP(&projectFlags.Paths, "paths", "d", []string{}, "filter projects by paths") err = cmd.RegisterFlagCompletionFunc("paths", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } options := config.GetProjectPaths() return options, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringVarP(&projectFlags.Target, "target", "T", "", "target projects by target name") err = cmd.RegisterFlagCompletionFunc("target", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } values := config.GetTargetNames() return values, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().BoolVarP(&projectFlags.Edit, "edit", "e", false, "edit project") return &cmd } func describeProjects( config *dao.Config, args []string, projectFlags *core.ProjectFlags, setProjectFlags *core.SetProjectFlags, describeFlags *core.DescribeFlags, ) { if projectFlags.Edit { if len(args) > 0 { err := config.EditProject(args[0]) core.CheckIfError(err) } else { err := config.EditProject("") core.CheckIfError(err) } } else { projectFlags.Projects = args if !setProjectFlags.All { // If no flags are set, use all and empty default target (but not the modified one by user) // If target is set, use the defaults from that target and respect other flags isNoFiltersSet := len(projectFlags.Projects) == 0 && len(projectFlags.Paths) == 0 && len(projectFlags.Tags) == 0 && projectFlags.TagsExpr == "" && !setProjectFlags.Cwd && !setProjectFlags.Target projectFlags.All = isNoFiltersSet } projects, err := config.GetFilteredProjects(projectFlags) core.CheckIfError(err) if len(projects) == 0 { fmt.Println("No matching projects found") } else { theme, err := config.GetTheme(describeFlags.Theme) core.CheckIfError(err) output := print.PrintProjectBlocks(projects, true, theme.Block, print.GookitFormatter{}) fmt.Print(output) } } } ================================================ FILE: cmd/describe_tasks.go ================================================ package cmd import ( "fmt" "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/print" ) func describeTasksCmd(config *dao.Config, configErr *error, describeFlags *core.DescribeFlags) *cobra.Command { var taskFlags core.TaskFlags cmd := cobra.Command{ Aliases: []string{"task", "tsk"}, Use: "tasks [tasks]", Short: "Describe tasks", Long: "Describe tasks.", Example: ` # Describe all tasks mani describe tasks # Describe task mani describe task `, Run: func(cmd *cobra.Command, args []string) { core.CheckIfError(*configErr) describe(config, args, taskFlags, describeFlags) }, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } values := config.GetTaskNames() return values, cobra.ShellCompDirectiveNoFileComp }, DisableAutoGenTag: true, } cmd.Flags().BoolVarP(&taskFlags.Edit, "edit", "e", false, "edit task") return &cmd } func describe( config *dao.Config, args []string, taskFlags core.TaskFlags, describeFlags *core.DescribeFlags, ) { if taskFlags.Edit { if len(args) > 0 { err := config.EditTask(args[0]) core.CheckIfError(err) } else { err := config.EditTask("") core.CheckIfError(err) } } else { tasks, err := config.GetTasksByNames(args) core.CheckIfError(err) if len(tasks) == 0 { fmt.Println("No tasks") } else { dao.ParseTasksEnv(tasks) theme, err := config.GetTheme(describeFlags.Theme) core.CheckIfError(err) out := print.PrintTaskBlock(tasks, true, theme.Block, print.GookitFormatter{}) fmt.Print(out) } } } ================================================ FILE: cmd/edit.go ================================================ package cmd import ( "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" ) func editCmd(config *dao.Config, configErr *error) *cobra.Command { cmd := cobra.Command{ Aliases: []string{"e", "ed"}, Use: "edit", Short: "Open up mani config file", Long: "Open up mani config file in $EDITOR.", Example: ` # Edit current context mani edit`, Run: func(cmd *cobra.Command, args []string) { err := *configErr switch e := err.(type) { case *core.ConfigNotFound: core.CheckIfError(e) default: runEdit(*config) } }, DisableAutoGenTag: true, } cmd.AddCommand( editTask(config, configErr), editProject(config, configErr), ) return &cmd } func runEdit(config dao.Config) { err := config.EditConfig() core.CheckIfError(err) } ================================================ FILE: cmd/edit_project.go ================================================ package cmd import ( "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" ) func editProject(config *dao.Config, configErr *error) *cobra.Command { cmd := cobra.Command{ Aliases: []string{"projects", "proj", "pr"}, Use: "project [project]", Short: "Edit mani project", Long: `Edit mani project in $EDITOR.`, Example: ` # Edit projects mani edit project # Edit project mani edit project `, Run: func(cmd *cobra.Command, args []string) { err := *configErr switch e := err.(type) { case *core.ConfigNotFound: core.CheckIfError(e) default: runEditProject(args, *config) } }, Args: cobra.MaximumNArgs(1), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil || len(args) == 1 { return []string{}, cobra.ShellCompDirectiveDefault } values := config.GetProjectNames() return values, cobra.ShellCompDirectiveNoFileComp }, DisableAutoGenTag: true, } return &cmd } func runEditProject(args []string, config dao.Config) { if len(args) > 0 { err := config.EditProject(args[0]) core.CheckIfError(err) } else { err := config.EditProject("") core.CheckIfError(err) } } ================================================ FILE: cmd/edit_task.go ================================================ package cmd import ( "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" ) func editTask(config *dao.Config, configErr *error) *cobra.Command { cmd := cobra.Command{ Aliases: []string{"tasks", "tsks", "tsk"}, Use: "task [task]", Short: "Edit mani task", Long: `Edit mani task in $EDITOR.`, Example: ` # Edit tasks mani edit task # Edit task mani edit task `, Run: func(cmd *cobra.Command, args []string) { err := *configErr switch e := err.(type) { case *core.ConfigNotFound: core.CheckIfError(e) default: runEditTask(args, *config) } }, Args: cobra.MaximumNArgs(1), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil || len(args) == 1 { return []string{}, cobra.ShellCompDirectiveDefault } values := config.GetTaskNames() return values, cobra.ShellCompDirectiveNoFileComp }, DisableAutoGenTag: true, } return &cmd } func runEditTask(args []string, config dao.Config) { if len(args) > 0 { err := config.EditTask(args[0]) core.CheckIfError(err) } else { err := config.EditTask("") core.CheckIfError(err) } } ================================================ FILE: cmd/exec.go ================================================ package cmd import ( "strings" "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/exec" ) func execCmd(config *dao.Config, configErr *error) *cobra.Command { var runFlags core.RunFlags var setRunFlags core.SetRunFlags cmd := cobra.Command{ Use: "exec ", Short: "Execute arbitrary commands", Long: `Execute arbitrary commands. Use single quotes around your command to prevent file globbing and environment variable expansion from occurring before the command is executed in each directory.`, Example: ` # List files in all projects mani exec --all ls # List git files with markdown suffix in all projects mani exec --all 'git ls-files | grep -e ".md"'`, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { core.CheckIfError(*configErr) // This is necessary since cobra doesn't support pointers for bools // (that would allow us to use nil as default value) setRunFlags.TTY = cmd.Flags().Changed("tty") setRunFlags.Parallel = cmd.Flags().Changed("parallel") setRunFlags.OmitEmptyRows = cmd.Flags().Changed("omit-empty-rows") setRunFlags.OmitEmptyColumns = cmd.Flags().Changed("omit-empty-columns") setRunFlags.IgnoreErrors = cmd.Flags().Changed("ignore-errors") setRunFlags.IgnoreNonExisting = cmd.Flags().Changed("ignore-non-existing") setRunFlags.Forks = cmd.Flags().Changed("forks") setRunFlags.Cwd = cmd.Flags().Changed("cwd") setRunFlags.All = cmd.Flags().Changed("all") if setRunFlags.Forks { forks, err := cmd.Flags().GetUint32("forks") core.CheckIfError(err) if forks == 0 { core.Exit(&core.ZeroNotAllowed{Name: "forks"}) } runFlags.Forks = forks } execute(args, config, &runFlags, &setRunFlags) }, DisableAutoGenTag: true, } cmd.Flags().BoolVar(&runFlags.TTY, "tty", false, "replace current process") cmd.Flags().BoolVar(&runFlags.DryRun, "dry-run", false, "print commands without executing them") cmd.Flags().BoolVarP(&runFlags.Silent, "silent", "s", false, "hide progress when running tasks") cmd.Flags().BoolVar(&runFlags.IgnoreNonExisting, "ignore-non-existing", false, "ignore non-existing projects") cmd.Flags().BoolVar(&runFlags.IgnoreErrors, "ignore-errors", false, "ignore errors") cmd.Flags().BoolVar(&runFlags.OmitEmptyRows, "omit-empty-rows", false, "omit empty rows in table output") cmd.Flags().BoolVar(&runFlags.OmitEmptyColumns, "omit-empty-columns", false, "omit empty columns in table output") cmd.Flags().BoolVar(&runFlags.Parallel, "parallel", false, "run tasks in parallel across projects") cmd.Flags().Uint32P("forks", "f", 4, "maximum number of concurrent processes") cmd.Flags().BoolVarP(&runFlags.Cwd, "cwd", "k", false, "use current working directory") cmd.Flags().BoolVarP(&runFlags.All, "all", "a", false, "target all projects") cmd.Flags().StringVarP(&runFlags.Output, "output", "o", "", "set output format [stream|table|markdown|html]") err := cmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } valid := []string{"table", "markdown", "html"} return valid, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringVarP(&runFlags.Spec, "spec", "J", "", "set spec") err = cmd.RegisterFlagCompletionFunc("spec", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } values := config.GetSpecNames() return values, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringSliceVarP(&runFlags.Projects, "projects", "p", []string{}, "select projects by name") err = cmd.RegisterFlagCompletionFunc("projects", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } projects := config.GetProjectNames() return projects, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringSliceVarP(&runFlags.Paths, "paths", "d", []string{}, "select projects by path") err = cmd.RegisterFlagCompletionFunc("paths", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } options := config.GetProjectPaths() return options, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringSliceVarP(&runFlags.Tags, "tags", "t", []string{}, "select projects by tag") err = cmd.RegisterFlagCompletionFunc("tags", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } tags := config.GetTags() return tags, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringVarP(&runFlags.TagsExpr, "tags-expr", "E", "", "select projects by tags expression") core.CheckIfError(err) cmd.Flags().StringVarP(&runFlags.Target, "target", "T", "", "target projects by target name") err = cmd.RegisterFlagCompletionFunc("target", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } values := config.GetTargetNames() return values, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.PersistentFlags().StringVar(&runFlags.Theme, "theme", "", "set theme") err = cmd.RegisterFlagCompletionFunc("theme", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } names := config.GetThemeNames() return names, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) return &cmd } func execute( args []string, config *dao.Config, runFlags *core.RunFlags, setRunFlags *core.SetRunFlags, ) { cmd := strings.Join(args[0:], " ") var tasks []dao.Task tasks, projects, err := dao.ParseCmd(cmd, runFlags, setRunFlags, config) core.CheckIfError(err) target := exec.Exec{Projects: projects, Tasks: tasks, Config: *config} err = target.Run([]string{}, runFlags, setRunFlags) core.CheckIfError(err) } ================================================ FILE: cmd/gen.go ================================================ package cmd import ( "github.com/spf13/cobra" "github.com/alajmo/mani/core" ) func genCmd() *cobra.Command { dir := "" cmd := cobra.Command{ Use: "gen", Short: "Generate man page", DisableFlagsInUseLine: true, Run: func(cmd *cobra.Command, args []string) { err := core.GenManPages(dir) core.CheckIfError(err) }, DisableAutoGenTag: true, } cmd.Flags().StringVarP(&dir, "dir", "d", "./", "directory to save manpage to") err := cmd.RegisterFlagCompletionFunc("dir", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return nil, cobra.ShellCompDirectiveFilterDirs }) core.CheckIfError(err) return &cmd } ================================================ FILE: cmd/gen_docs.go ================================================ // This source will generate // - core/mani.1 // - docs/commands.md // // and is not included in the final build. package cmd import ( "github.com/spf13/cobra" "github.com/alajmo/mani/core" ) func genDocsCmd(longAppDesc string) *cobra.Command { cmd := cobra.Command{ Use: "gen-docs", Short: "Generate man and markdown pages", DisableFlagsInUseLine: true, Run: func(cmd *cobra.Command, args []string) { err := core.CreateManPage( longAppDesc, version, date, rootCmd, runCmd(&config, &configErr), execCmd(&config, &configErr), initCmd(), syncCmd(&config, &configErr), editCmd(&config, &configErr), listCmd(&config, &configErr), describeCmd(&config, &configErr), tuiCmd(&config, &configErr), checkCmd(&configErr), genCmd(), ) core.CheckIfError(err) }, DisableAutoGenTag: true, } return &cmd } ================================================ FILE: cmd/init.go ================================================ package cmd import ( "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/exec" ) func initCmd() *cobra.Command { var initFlags core.InitFlags cmd := cobra.Command{ Use: "init", Short: "Initialize a mani repository", Long: `Initialize a mani repository. Creates a new mani repository by generating a mani.yaml configuration file and a .gitignore file in the current directory.`, Example: ` # Initialize with default settings mani init # Initialize without auto-discovering projects mani init --auto-discovery=false # Initialize without updating .gitignore mani init --sync-gitignore=false`, Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { foundProjects, err := dao.InitMani(args, initFlags) core.CheckIfError(err) if initFlags.AutoDiscovery { exec.PrintProjectInit(foundProjects) } }, DisableAutoGenTag: true, } cmd.Flags().BoolVar(&initFlags.AutoDiscovery, "auto-discovery", true, "automatically discover and add Git repositories to mani.yaml") cmd.Flags().BoolVarP(&initFlags.SyncGitignore, "sync-gitignore", "g", true, "synchronize .gitignore file") return &cmd } ================================================ FILE: cmd/list.go ================================================ package cmd import ( "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" ) func listCmd(config *dao.Config, configErr *error) *cobra.Command { var listFlags core.ListFlags cmd := cobra.Command{ Aliases: []string{"ls", "l"}, Use: "list", Short: "List projects, tasks and tags", Long: "List projects, tasks and tags.", Example: ` # List all projects mani list projects # List all tasks mani list tasks # List all tags mani list tags`, DisableAutoGenTag: true, } cmd.AddCommand( listProjectsCmd(config, configErr, &listFlags), listTasksCmd(config, configErr, &listFlags), listTagsCmd(config, configErr, &listFlags), ) cmd.PersistentFlags().StringVar(&listFlags.Theme, "theme", "default", "set theme") err := cmd.RegisterFlagCompletionFunc("theme", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } names := config.GetThemeNames() return names, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.PersistentFlags().StringVarP(&listFlags.Output, "output", "o", "table", "set output format [table|markdown|html]") err = cmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } valid := []string{"table", "markdown", "html"} return valid, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) return &cmd } ================================================ FILE: cmd/list_projects.go ================================================ package cmd import ( "fmt" "os" "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/print" ) func listProjectsCmd( config *dao.Config, configErr *error, listFlags *core.ListFlags, ) *cobra.Command { var projectFlags core.ProjectFlags var setProjectFlags core.SetProjectFlags cmd := cobra.Command{ Aliases: []string{"project", "proj", "pr"}, Use: "projects [projects]", Short: "List projects", Long: "List projects.", Example: ` # List all projects mani list projects # List projects by name mani list projects # List projects by tags mani list projects --tags # List projects by paths mani list projects --paths # List projects matching a tag expression mani run --tags-expr ' || '`, Run: func(cmd *cobra.Command, args []string) { core.CheckIfError(*configErr) setProjectFlags.All = cmd.Flags().Changed("all") setProjectFlags.Cwd = cmd.Flags().Changed("cwd") setProjectFlags.Target = cmd.Flags().Changed("target") listProjects(config, args, listFlags, &projectFlags, &setProjectFlags) }, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } projectNames := config.GetProjectNames() return projectNames, cobra.ShellCompDirectiveNoFileComp }, DisableAutoGenTag: true, } cmd.Flags().BoolVar(&listFlags.Tree, "tree", false, "display output in tree format") cmd.Flags().BoolVarP(&projectFlags.All, "all", "a", true, "select all projects") cmd.Flags().BoolVarP(&projectFlags.Cwd, "cwd", "k", false, "select current working directory") cmd.Flags().StringSliceVarP(&projectFlags.Tags, "tags", "t", []string{}, "select projects by tags") err := cmd.RegisterFlagCompletionFunc("tags", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } options := config.GetTags() return options, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringVarP(&projectFlags.TagsExpr, "tags-expr", "E", "", "select projects by tags expression") core.CheckIfError(err) cmd.Flags().StringSliceVarP(&projectFlags.Paths, "paths", "d", []string{}, "select projects by paths") err = cmd.RegisterFlagCompletionFunc("paths", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } options := config.GetProjectPaths() return options, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringVarP(&projectFlags.Target, "target", "T", "", "select projects by target name") err = cmd.RegisterFlagCompletionFunc("target", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } values := config.GetTargetNames() return values, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringSliceVar(&projectFlags.Headers, "headers", []string{"project", "tag", "description"}, "specify columns to display [project, path, relpath, description, url, tag, worktree]") err = cmd.RegisterFlagCompletionFunc("headers", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if err != nil { return []string{}, cobra.ShellCompDirectiveDefault } validHeaders := []string{"project", "path", "relpath", "description", "url", "tag", "worktree"} return validHeaders, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) return &cmd } func listProjects( config *dao.Config, args []string, listFlags *core.ListFlags, projectFlags *core.ProjectFlags, setProjectFlags *core.SetProjectFlags, ) { theme, err := config.GetTheme(listFlags.Theme) core.CheckIfError(err) if listFlags.Tree { tree, err := config.GetProjectsTree(projectFlags.Paths, projectFlags.Tags) core.CheckIfError(err) print.PrintTree(config, *theme, listFlags, tree) return } projectFlags.Projects = args // If flag All is not set and no other filters are applied set All to true. if !setProjectFlags.All { isNoFiltersSet := len(projectFlags.Projects) == 0 && len(projectFlags.Paths) == 0 && len(projectFlags.Tags) == 0 && projectFlags.TagsExpr == "" && !setProjectFlags.Cwd && !setProjectFlags.Target projectFlags.All = isNoFiltersSet } projects, err := config.GetFilteredProjects(projectFlags) core.CheckIfError(err) if len(projects) == 0 { fmt.Println("No matching projects found") } else { theme.Table.Border.Rows = core.Ptr(false) theme.Table.Header.Format = core.Ptr("t") options := print.PrintTableOptions{ Output: listFlags.Output, Theme: *theme, Tree: listFlags.Tree, AutoWrap: true, OmitEmptyRows: false, OmitEmptyColumns: true, Color: *theme.Color, } fmt.Println() print.PrintTable(projects, options, projectFlags.Headers, []string{}, os.Stdout) fmt.Println() } } ================================================ FILE: cmd/list_tags.go ================================================ package cmd import ( "fmt" "os" "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/print" ) func listTagsCmd(config *dao.Config, configErr *error, listFlags *core.ListFlags) *cobra.Command { var tagFlags core.TagFlags cmd := cobra.Command{ Aliases: []string{"tag"}, Use: "tags [tags]", Short: "List tags", Long: "List tags.", Example: ` # List all tags mani list tags`, Run: func(cmd *cobra.Command, args []string) { core.CheckIfError(*configErr) listTags(config, args, listFlags, &tagFlags) }, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } tags := config.GetTags() return tags, cobra.ShellCompDirectiveNoFileComp }, DisableAutoGenTag: true, } cmd.Flags().StringSliceVar(&tagFlags.Headers, "headers", []string{"tag", "project"}, "specify columns to display [project, tag]") err := cmd.RegisterFlagCompletionFunc("headers", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } validHeaders := []string{"tag", "project"} return validHeaders, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) return &cmd } func listTags( config *dao.Config, args []string, listFlags *core.ListFlags, tagFlags *core.TagFlags, ) { theme, err := config.GetTheme(listFlags.Theme) core.CheckIfError(err) theme.Table.Border.Rows = core.Ptr(false) theme.Table.Header.Format = core.Ptr("t") options := print.PrintTableOptions{ Output: listFlags.Output, Theme: *theme, Tree: listFlags.Tree, AutoWrap: true, OmitEmptyRows: false, OmitEmptyColumns: true, Color: *theme.Color, } allTags := config.GetTags() if len(args) > 0 { foundTags := core.Intersection(args, allTags) // Could not find one of the provided tags if len(foundTags) != len(args) { core.CheckIfError(&core.TagNotFound{Tags: args}) } tags, err := config.GetTagAssocations(foundTags) core.CheckIfError(err) if len(tags) == 0 { fmt.Println("No tags") } else { fmt.Println() print.PrintTable(tags, options, tagFlags.Headers, []string{}, os.Stdout) fmt.Println() } } else { tags, err := config.GetTagAssocations(allTags) core.CheckIfError(err) if len(tags) == 0 { fmt.Println("No tags") } else { fmt.Println("") print.PrintTable(tags, options, tagFlags.Headers, []string{}, os.Stdout) fmt.Println("") } } } ================================================ FILE: cmd/list_tasks.go ================================================ package cmd import ( "fmt" "os" "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/print" ) func listTasksCmd(config *dao.Config, configErr *error, listFlags *core.ListFlags) *cobra.Command { var taskFlags core.TaskFlags cmd := cobra.Command{ Aliases: []string{"task", "tsk", "tsks"}, Use: "tasks [tasks]", Short: "List tasks", Long: "List tasks.", Example: ` # List all tasks mani list tasks # List tasks by name mani list task `, Run: func(cmd *cobra.Command, args []string) { core.CheckIfError(*configErr) listTasks(config, args, listFlags, &taskFlags) }, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } values := config.GetTaskNames() return values, cobra.ShellCompDirectiveNoFileComp }, DisableAutoGenTag: true, } cmd.Flags().StringSliceVar(&taskFlags.Headers, "headers", []string{"task", "description"}, "specify columns to display [task, description, target, spec]") err := cmd.RegisterFlagCompletionFunc("headers", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } validHeaders := []string{"task", "description", "target", "spec"} return validHeaders, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) return &cmd } func listTasks( config *dao.Config, args []string, listFlags *core.ListFlags, taskFlags *core.TaskFlags, ) { tasks, err := config.GetTasksByNames(args) core.CheckIfError(err) theme, err := config.GetTheme(listFlags.Theme) core.CheckIfError(err) if len(tasks) == 0 { fmt.Println("No tasks") } else { theme.Table.Border.Rows = core.Ptr(false) theme.Table.Header.Format = core.Ptr("t") options := print.PrintTableOptions{ Output: listFlags.Output, Theme: *theme, Tree: listFlags.Tree, AutoWrap: true, OmitEmptyRows: false, OmitEmptyColumns: true, Color: *theme.Color, } fmt.Println() print.PrintTable(tasks, options, taskFlags.Headers, []string{}, os.Stdout) fmt.Println() } } ================================================ FILE: cmd/root.go ================================================ package cmd import ( "fmt" "os" "runtime" "github.com/spf13/cobra" "github.com/alajmo/mani/core/dao" ) const ( appName = "mani" shortAppDesc = "repositories manager and task runner" ) var ( config dao.Config configErr error configFilepath string userConfigPath string color bool buildMode = "" version = "dev" commit = "none" date = "n/a" rootCmd = &cobra.Command{ Use: appName, Short: shortAppDesc, Version: version, } ) func Execute() { if err := rootCmd.Execute(); err != nil { // When user input's wrong command or flag os.Exit(1) } } func init() { // Modify default shell in-case we're on windows if runtime.GOOS == "windows" { dao.DEFAULT_SHELL = "powershell -NoProfile" dao.DEFAULT_SHELL_PROGRAM = "powershell" } cobra.OnInitialize(initConfig) rootCmd.PersistentFlags().StringVarP(&configFilepath, "config", "c", "", "specify config") rootCmd.PersistentFlags().StringVarP(&userConfigPath, "user-config", "u", "", "specify user config") rootCmd.PersistentFlags().BoolVar(&color, "color", true, "enable color") rootCmd.AddCommand( completionCmd(), genCmd(), initCmd(), execCmd(&config, &configErr), runCmd(&config, &configErr), listCmd(&config, &configErr), describeCmd(&config, &configErr), syncCmd(&config, &configErr), editCmd(&config, &configErr), checkCmd(&configErr), tuiCmd(&config, &configErr), ) rootCmd.SetVersionTemplate(fmt.Sprintf("Version: %-10s\nCommit: %-10s\nDate: %-10s\n", version, commit, date)) // Add custom help template with footer defaultHelpTemplate := rootCmd.HelpTemplate() rootCmd.SetHelpTemplate(defaultHelpTemplate + ` Documentation: https://manicli.com Issues: https://github.com/alajmo/mani/issues `) if buildMode == "man" { rootCmd.AddCommand(genDocsCmd("manage multiple repositories and run commands across them")) } rootCmd.DisableAutoGenTag = true } func initConfig() { config, configErr = dao.ReadConfig(configFilepath, userConfigPath, color) } ================================================ FILE: cmd/run.go ================================================ package cmd import ( "strings" "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/exec" ) func runCmd(config *dao.Config, configErr *error) *cobra.Command { var runFlags core.RunFlags var setRunFlags core.SetRunFlags cmd := cobra.Command{ Use: "run ", Short: "Run tasks", Long: `Run tasks. The tasks are specified in a mani.yaml file along with the projects you can target.`, Example: ` # Execute task for all projects mani run --all # Execute a task in parallel with a maximum of 8 concurrent processes mani run --projects --parallel --forks 8 # Execute task for a specific projects mani run --projects # Execute a task for projects with specific tags mani run --tags # Execute a task for projects matching specific paths mani run --paths # Execute a task for all projects matching a tag expression mani run --tags-expr 'active || git' # Execute a task with environment variables from shell mani run key=value`, DisableFlagsInUseLine: true, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { core.CheckIfError(*configErr) // This is necessary since cobra doesn't support pointers for bools // (that would allow us to use nil as default value) setRunFlags.TTY = cmd.Flags().Changed("tty") setRunFlags.Cwd = cmd.Flags().Changed("cwd") setRunFlags.All = cmd.Flags().Changed("all") setRunFlags.Parallel = cmd.Flags().Changed("parallel") setRunFlags.OmitEmptyRows = cmd.Flags().Changed("omit-empty-rows") setRunFlags.OmitEmptyColumns = cmd.Flags().Changed("omit-empty-columns") setRunFlags.IgnoreErrors = cmd.Flags().Changed("ignore-errors") setRunFlags.IgnoreNonExisting = cmd.Flags().Changed("ignore-non-existing") setRunFlags.Forks = cmd.Flags().Changed("forks") if setRunFlags.Forks { forks, err := cmd.Flags().GetUint32("forks") core.CheckIfError(err) if forks == 0 { core.Exit(&core.ZeroNotAllowed{Name: "forks"}) } runFlags.Forks = forks } run(args, config, &runFlags, &setRunFlags) }, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } return config.GetTaskNameAndDesc(), cobra.ShellCompDirectiveNoFileComp }, DisableAutoGenTag: true, } cmd.Flags().BoolVar(&runFlags.TTY, "tty", false, "replace current process") cmd.Flags().BoolVar(&runFlags.Describe, "describe", false, "display task information") cmd.Flags().BoolVar(&runFlags.DryRun, "dry-run", false, "display the task without execution") cmd.Flags().BoolVarP(&runFlags.Silent, "silent", "s", false, "hide progress output during task execution") cmd.Flags().BoolVar(&runFlags.IgnoreNonExisting, "ignore-non-existing", false, "skip non-existing projects") cmd.Flags().BoolVar(&runFlags.IgnoreErrors, "ignore-errors", false, "continue execution despite errors") cmd.Flags().BoolVar(&runFlags.OmitEmptyRows, "omit-empty-rows", false, "hide empty rows in table output") cmd.Flags().BoolVar(&runFlags.OmitEmptyColumns, "omit-empty-columns", false, "hide empty columns in table output") cmd.Flags().BoolVar(&runFlags.Parallel, "parallel", false, "execute tasks in parallel across projects") cmd.Flags().BoolVarP(&runFlags.Edit, "edit", "e", false, "edit task") cmd.Flags().Uint32P("forks", "f", 4, "maximum number of concurrent processes") cmd.Flags().StringVarP(&runFlags.Output, "output", "o", "", "set output format [stream|table|markdown|html]") err := cmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } valid := []string{"stream", "table", "html", "markdown"} return valid, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringVarP(&runFlags.Spec, "spec", "J", "", "set spec") err = cmd.RegisterFlagCompletionFunc("spec", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } values := config.GetSpecNames() return values, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().BoolVarP(&runFlags.Cwd, "cwd", "k", false, "select current working directory") cmd.Flags().BoolVarP(&runFlags.All, "all", "a", false, "select all projects") cmd.Flags().StringSliceVarP(&runFlags.Projects, "projects", "p", []string{}, "select projects by name") err = cmd.RegisterFlagCompletionFunc("projects", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } projects := config.GetProjectNames() return projects, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringSliceVarP(&runFlags.Paths, "paths", "d", []string{}, "select projects by path") err = cmd.RegisterFlagCompletionFunc("paths", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } options := config.GetProjectPaths() return options, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringSliceVarP(&runFlags.Tags, "tags", "t", []string{}, "select projects by tag") err = cmd.RegisterFlagCompletionFunc("tags", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } tags := config.GetTags() return tags, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringVarP(&runFlags.TagsExpr, "tags-expr", "E", "", "select projects by tags expression") core.CheckIfError(err) cmd.Flags().StringVarP(&runFlags.Target, "target", "T", "", "select projects by target name") err = cmd.RegisterFlagCompletionFunc("target", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } values := config.GetTargetNames() return values, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.PersistentFlags().StringVar(&runFlags.Theme, "theme", "", "set theme") err = cmd.RegisterFlagCompletionFunc("theme", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } names := config.GetThemeNames() return names, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) return &cmd } func run( args []string, config *dao.Config, runFlags *core.RunFlags, setRunFlags *core.SetRunFlags, ) { var taskNames []string var userArgs []string // Separate user arguments from task names for _, arg := range args { if strings.Contains(arg, "=") { userArgs = append(userArgs, arg) } else { taskNames = append(taskNames, arg) } } if runFlags.Edit { if len(args) > 0 { _ = config.EditTask(taskNames[0]) return } else { _ = config.EditTask("") return } } var tasks []dao.Task var projects []dao.Project var err error if len(taskNames) == 1 { tasks, projects, err = dao.ParseSingleTask(taskNames[0], runFlags, setRunFlags, config) } else { tasks, projects, err = dao.ParseManyTasks(taskNames, runFlags, setRunFlags, config) } core.CheckIfError(err) target := exec.Exec{Projects: projects, Tasks: tasks, Config: *config} err = target.Run(userArgs, runFlags, setRunFlags) core.CheckIfError(err) } ================================================ FILE: cmd/sync.go ================================================ package cmd import ( "github.com/spf13/cobra" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/exec" ) func syncCmd(config *dao.Config, configErr *error) *cobra.Command { var projectFlags core.ProjectFlags var syncFlags = core.SyncFlags{Forks: 4} var setSyncFlags core.SetSyncFlags cmd := cobra.Command{ Use: "sync", Aliases: []string{"clone"}, Short: "Clone repositories and update .gitignore", Long: `Clone repositories and update .gitignore file. For repositories requiring authentication, disable parallel cloning to enter credentials for each repository individually.`, Example: ` # Clone repositories one at a time mani sync # Clone repositories in parallel mani sync --parallel # Disable updating .gitignore file mani sync --sync-gitignore=false # Sync project remotes. This will modify the projects .git state mani sync --sync-remotes # Clone repositories even if project sync field is set to false mani sync --ignore-sync-state # Display sync status mani sync --status`, Run: func(cmd *cobra.Command, args []string) { core.CheckIfError(*configErr) // This is necessary since cobra doesn't support pointers for bools // (that would allow us to use nil as default value) setSyncFlags.Parallel = cmd.Flags().Changed("parallel") setSyncFlags.SyncGitignore = cmd.Flags().Changed("sync-gitignore") setSyncFlags.SyncRemotes = cmd.Flags().Changed("sync-remotes") setSyncFlags.RemoveOrphanedWorktrees = cmd.Flags().Changed("remove-orphaned-worktrees") setSyncFlags.Forks = cmd.Flags().Changed("forks") if setSyncFlags.Forks { forks, err := cmd.Flags().GetUint32("forks") core.CheckIfError(err) if forks == 0 { core.Exit(&core.ZeroNotAllowed{Name: "forks"}) } syncFlags.Forks = forks } runSync(config, args, projectFlags, syncFlags, setSyncFlags) }, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } projectNames := config.GetProjectNames() return projectNames, cobra.ShellCompDirectiveNoFileComp }, DisableAutoGenTag: true, } cmd.Flags().BoolVarP(&syncFlags.SyncRemotes, "sync-remotes", "r", false, "update git remote state") cmd.Flags().BoolVarP(&syncFlags.RemoveOrphanedWorktrees, "remove-orphaned-worktrees", "w", false, "remove git worktrees not in config") cmd.Flags().BoolVarP(&syncFlags.SyncGitignore, "sync-gitignore", "g", true, "sync gitignore") cmd.Flags().BoolVar(&syncFlags.IgnoreSyncState, "ignore-sync-state", false, "sync project even if the project's sync field is set to false") cmd.Flags().BoolVarP(&syncFlags.Parallel, "parallel", "p", false, "clone projects in parallel") cmd.Flags().BoolVarP(&syncFlags.Status, "status", "s", false, "display status only") cmd.Flags().Uint32P("forks", "f", 4, "maximum number of concurrent processes") // Targets cmd.Flags().StringSliceVarP(&projectFlags.Tags, "tags", "t", []string{}, "clone projects by tags") err := cmd.RegisterFlagCompletionFunc("tags", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } options := config.GetTags() return options, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().StringVarP(&projectFlags.TagsExpr, "tags-expr", "E", "", "clone projects by tag expression") core.CheckIfError(err) cmd.Flags().StringSliceVarP(&projectFlags.Paths, "paths", "d", []string{}, "clone projects by path") err = cmd.RegisterFlagCompletionFunc("paths", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } options := config.GetProjectPaths() return options, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) return &cmd } func runSync( config *dao.Config, args []string, projectFlags core.ProjectFlags, syncFlags core.SyncFlags, setSyncFlags core.SetSyncFlags, ) { // If no flag is set for targetting projects, then assume all projects var allProjects bool if len(args) == 0 && projectFlags.TagsExpr == "" && len(projectFlags.Paths) == 0 && len(projectFlags.Tags) == 0 { allProjects = true } projects, err := config.FilterProjects(false, allProjects, args, projectFlags.Paths, projectFlags.Tags, projectFlags.TagsExpr) core.CheckIfError(err) if !syncFlags.Status { if setSyncFlags.SyncRemotes { config.SyncRemotes = &syncFlags.SyncRemotes } if setSyncFlags.RemoveOrphanedWorktrees { config.RemoveOrphanedWorktrees = &syncFlags.RemoveOrphanedWorktrees } if setSyncFlags.SyncGitignore { config.SyncGitignore = &syncFlags.SyncGitignore } if *config.SyncGitignore { err := exec.UpdateGitignoreIfExists(config) core.CheckIfError(err) } err = exec.CloneRepos(config, projects, syncFlags) core.CheckIfError(err) } err = exec.PrintProjectStatus(config, projects) core.CheckIfError(err) } ================================================ FILE: cmd/tui.go ================================================ package cmd import ( "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/tui" "github.com/spf13/cobra" ) func tuiCmd(config *dao.Config, configErr *error) *cobra.Command { var tuiFlags core.TUIFlags cmd := cobra.Command{ Use: "tui", Aliases: []string{"gui"}, Short: "TUI", Long: `Run TUI`, Example: ` # Open tui mani tui`, Run: func(cmd *cobra.Command, args []string) { core.CheckIfError(*configErr) reloadChanged := cmd.Flags().Changed("reload-on-change") reload := config.ReloadTUI if reloadChanged { reload = &tuiFlags.Reload } tui.RunTui(config, tuiFlags.Theme, *reload) }, DisableAutoGenTag: true, } cmd.PersistentFlags().StringVar(&tuiFlags.Theme, "theme", "default", "set theme") err := cmd.RegisterFlagCompletionFunc("theme", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if *configErr != nil { return []string{}, cobra.ShellCompDirectiveDefault } names := config.GetThemeNames() return names, cobra.ShellCompDirectiveDefault }) core.CheckIfError(err) cmd.Flags().BoolVarP(&tuiFlags.Reload, "reload-on-change", "r", false, "reload mani on config change") return &cmd } ================================================ FILE: core/config.man ================================================ .SH CONFIG The mani.yaml config is based on the following concepts: .RS 2 .IP "\(bu" 2 \fBprojects\fR are directories, which may be git repositories, in which case they have an URL attribute .PD 0 .IP "\(bu" 2 \fBtasks\fR are shell commands that you write and then run for selected \fBprojects\fR .IP "\(bu" 2 \fBspecs\fR are configs that alter \fBtask\fR execution and output .PD 0 .IP "\(bu" 2 \fBtargets\fR are configs that provide shorthand filtering of \fBprojects\fR when executing tasks .PD 0 .IP "\(bu" 2 \fBenv\fR are environment variables that can be defined globally, per project and per task .PD 0 .RE \fBSpecs\fR, \fBtargets\fR and \fBthemes\fR use a \fBdefault\fR object by default that the user can override to modify execution of mani commands. Check the files and environment section to see how the config file is loaded. Below is a config file detailing all of the available options and their defaults. .RS 4 # Import projects/tasks/env/specs/themes/targets from other configs import: - ./some-dir/mani.yaml # Shell used for commands # If you use any other program than bash, zsh, sh, node, and python # then you have to provide the command flag if you want the command-line string evaluted # For instance: bash -c shell: bash # If set to true, mani will override the URL of any existing remote # and remove remotes not found in the config sync_remotes: false # Determines whether the .gitignore should be updated when syncing projects sync_gitignore: true # When running the TUI, specifies whether it should reload when the mani config is changed reload_tui_on_change: false # List of Projects projects: # Project name [required] pinto: # Determines if the project should be synchronized during 'mani sync' sync: true # Project path relative to the config file # Defaults to project name if not specified path: frontend/pinto # Repository URL url: git@github.com:alajmo/pinto # Project description desc: A vim theme editor # Custom clone command # Defaults to "git clone URL PATH" clone: git clone git@github.com:alajmo/pinto --branch main # Branch to use as primary HEAD when cloning # Defaults to repository's primary HEAD branch: # When true, clones only the specified branch or primary HEAD single_branch: false # Project tags tags: [dev] # Remote repositories # Key is the remote name, value is the URL remotes: foo: https://github.com/bar # Project-specific environment variables env: # Simple string value branch: main # Shell command substitution date: $(date -u +"%Y-%m-%dT%H:%M:%S%Z") # List of Specs specs: default: # Output format for task results # Options: stream, table, html, markdown output: stream # Enable parallel task execution parallel: false # Maximum number of concurrent tasks when running in parallel forks: 4 # When true, continues execution if a command fails in a multi-command task ignore_errors: false # When true, skips project entries in the config that don't exist # on the filesystem without throwing an error ignore_non_existing: false # Hide projects with no command output omit_empty_rows: false # Hide columns with no data omit_empty_columns: false # Clear screen before task execution (TUI only) clear_output: true # List of targets targets: default: # Select all projects all: false # Select project in current working directory cwd: false # Select projects by name projects: [] # Select projects by path paths: [] # Select projects by tag tags: [] # Select projects by tag expression tags_expr: "" # Environment variables available to all tasks env: # Simple string value AUTHOR: "alajmo" # Shell command substitution DATE: $(date -u +"%Y-%m-%dT%H:%M:%S%Z") # List of tasks tasks: # Command name [required] simple-2: echo "hello world" # Command name [required] simple-1: cmd: | echo "hello world" desc: simple command 1 # Command name [required] advanced-command: # Task description desc: complex task # Task theme theme: default # Shell interpreter shell: bash # Task-specific environment variables env: # Static value branch: main # Dynamic shell command output num_lines: $(ls -1 | wc -l) # Can reference predefined spec: # spec: custom_spec # or define inline: spec: output: table parallel: true forks: 4 ignore_errors: false ignore_non_existing: true omit_empty_rows: true omit_empty_columns: true # Can reference predefined target: # target: custom_target # or define inline: target: all: true cwd: false projects: [pinto] paths: [frontend] tags: [dev] tags_expr: (prod || dev) && !test # Single multi-line command cmd: | echo complex echo command # Multiple commands commands: # Node.js command example - name: node-example shell: node cmd: console.log("hello world from node.js"); # Reference to another task - task: simple-1 # List of themes # Styling Options: # Fg (foreground color): Empty string (""), hex color, or named color from W3C standard # Bg (background color): Empty string (""), hex color, or named color from W3C standard # Format: Empty string (""), "lower", "title", "upper" # Attribute: Empty string (""), "bold", "italic", "underline" # Alignment: Empty string (""), "left", "center", "right" themes: # Theme name [required] default: # Stream Output Configuration stream: # Include project name prefix for each line prefix: true # Colors to alternate between for each project prefix prefix_colors: ["#d787ff", "#00af5f", "#d75f5f", "#5f87d7", "#00af87", "#5f00ff"] # Add a header before each project header: true # String value that appears before the project name in the header header_prefix: "TASK" # Fill remaining spaces with a character after the prefix header_char: "*" # Table Output Configuration table: # Table style # Available options: ascii, light, bold, double, rounded style: ascii # Border options for table output border: around: false # Border around the table columns: true # Vertical border between columns header: true # Horizontal border between headers and rows rows: false # Horizontal border between rows header: fg: "#d787ff" attr: bold format: "" title_column: fg: "#5f87d7" attr: bold format: "" # Tree View Configuration tree: # Tree style # Available options: ascii, light, bold, double, rounded, bullet-square, bullet-circle, bullet-star style: ascii # Block Display Configuration block: key: fg: "#5f87d7" separator: fg: "#5f87d7" value: fg: value_true: fg: "#00af5f" value_false: fg: "#d75f5f" # TUI Configuration tui: default: fg: bg: attr: border: fg: border_focus: fg: "#d787ff" title: fg: bg: attr: align: center title_active: fg: "#000000" bg: "#d787ff" attr: align: center button: fg: bg: attr: format: button_active: fg: "#080808" bg: "#d787ff" attr: format: table_header: fg: "#d787ff" bg: attr: bold align: left format: item: fg: bg: attr: item_focused: fg: "#ffffff" bg: "#262626" attr: item_selected: fg: "#5f87d7" bg: attr: item_dir: fg: "#d787ff" bg: attr: item_ref: fg: "#d787ff" bg: attr: search_label: fg: "#d7d75f" bg: attr: bold search_text: fg: bg: attr: filter_label: fg: "#d7d75f" bg: attr: bold filter_text: fg: bg: attr: shortcut_label: fg: "#00af5f" bg: attr: shortcut_text: fg: bg: attr: .RE .SH EXAMPLES .TP Initialize mani .B samir@hal-9000 ~ $ mani init .nf Initialized mani repository in /tmp - Created mani.yaml - Created .gitignore Following projects were added to mani.yaml Project | Path ----------+------------ test | . pinto | dev/pinto .fi .TP Clone projects .B samir@hal-9000 ~ $ mani sync --parallel --forks 8 .nf pinto | Cloning into '/tmp/dev/pinto'... Project | Synced ----------+-------- test | ✓ pinto | ✓ .fi .TP List all projects .B samir@hal-9000 ~ $ mani list projects .nf Project --------- test pinto .fi .TP List all projects with output set to tree .nf .B samir@hal-9000 ~ $ mani list projects --tree ── dev └─ pinto .fi .nf .TP List all tags .B samir@hal-9000 ~ $ mani list tags .nf Tag | Project -----+--------- dev | pinto .fi .TP List all tasks .nf .B samir@hal-9000 ~ $ mani list tasks Task | Description ------------------+------------------ simple-1 | simple command 1 simple-2 | advanced-command | complex task .fi .TP Describe a task .nf .B samir@hal-9000 ~ $ mani describe tasks advanced-command Name: advanced-command Description: complex task Theme: default Target: All: true Cwd: false Projects: pinto Paths: frontend Tags: dev TagsExpr: "" Spec: Output: table Parallel: true Forks: 4 IgnoreErrors: false IgnoreNonExisting: false OmitEmptyRows: false OmitEmptyColumns: false Env: branch: dev num_lines: 2 Cmd: echo advanced echo command Commands: - simple-1 - simple-2 - cmd .fi .TP Run a task for all projects with tag 'dev' .nf .B samir@hal-9000 ~ $ mani run simple-1 --tags dev Project | Simple-1 ---------+------------- pinto | hello world .fi .TP Run a task for all projects matching tags expression 'dev && !prod' .nf .B samir@hal-9000 ~ $ mani run simple-1 --tags-expr '(dev && !prod)' Project | Simple-1 ---------+------------- pinto | hello world .fi .TP Run ad-hoc command for all projects .nf .B samir@hal-9000 ~ $ mani exec 'echo 123' --all Project | Output ---------+-------- archive | 123 pinto | 123 .fi .SH FILTERING PROJECTS Projects can be filtered when managing projects (sync, list, describe) or running tasks. Filters can be specified through CLI flags or target configurations. The filtering is inclusive, meaning projects must satisfy all specified filters to be included in the results. .PP Available options: .RS 2 .IP "\(bu" 2 cwd: include only the project under the current working directory, ignoring all other filters .IP "\(bu" 2 all: include all projects .IP "\(bu" 2 projects: Filter by project names .IP "\(bu" 2 paths: Filter by project paths .IP "\(bu" 2 tags: Filter by project tags .IP "\(bu" 2 tags_expr: Filter using tag logic expressions .IP "\(bu" 2 target: Filter using target .RE .PP For \fBmani sync/list/describe\fR: .RS 2 .IP "\(bu" 2 No filters: Targets all projects .IP "\(bu" 2 Multiple filters: Select intersection of projects/paths/tags/tags_expr/target filters .RE For \fBmani run/exec\fR: .RS 2 .IP "1." 4 Runtime flags (highest priority) .IP "2." 4 Target flag configuration (\fB--target\fR) .IP "3." 4 Task's default target data (lowest priority) .RE The default target is named `default` and can be overridden by defining a target named `default` in the config. This only applies for sub-commands `run` and `exec`. .SH TAGS EXPRESSION Tag expressions allow filtering projects using boolean operations on their tags. The expression is evaluated for each project's tags to determine if the project should be included. .PP Operators (in precedence order): .RS 2 .IP "\(bu" 2 (): Parentheses for grouping .PD 0 .IP "\(bu" 2 !: NOT operator (logical negation) .PD 0 .IP "\(bu" 2 &&: AND operator (logical conjunction) .PD 0 .IP "\(bu" 2 ||: OR operator (logical disjunction) .RE .PP For example, the expression: \fB(main && (dev || prod)) && !test\fR .PP requires the projects to pass these conditions: .RS 2 .IP "\(bu" 2 Must have "main" tag .PD 0 .IP "\(bu" 2 Must have either "dev" OR "prod" tag .IP "\(bu" 2 Must NOT have "test" tag .PD 0 .RE .SH FILES When running a command, .B mani will check the current directory and all parent directories for the following files: mani.yaml, mani.yml, .mani.yaml, .mani.yml. Additionally, it will import (if found) a config file from: .RS 2 .IP "\(bu" 2 Linux: \fB$XDG_CONFIG_HOME/mani/config.yaml\fR or \fB$HOME/.config/mani/config.yaml\fR if \fB$XDG_CONFIG_HOME\fR is not set. .IP "\(bu" 2 Darwin: \fB$HOME/Library/Application Support/mani/config.yaml\fR .IP "\(bu" 2 Windows: \fB%AppData%\mani\fR .RE Both the config and user config can be specified via flags or environments variables. .SH ENVIRONMENT .TP .B MANI_CONFIG Override config file path .TP .B MANI_USER_CONFIG Override user config file path .TP .B NO_COLOR If this env variable is set (regardless of value) then all colors will be disabled .SH BUGS See GitHub Issues: .UR https://github.com/alajmo/mani/issues .ME . .SH AUTHOR .B mani was written by Samir Alajmovic .MT alajmovic.samir@gmail.com .ME . For updates and more information go to .UR https://\:www.manicli.com manicli.com .UE . ================================================ FILE: core/dao/benchmark_test.go ================================================ package dao import ( "fmt" "testing" ) // Helper to create a config with N projects, M tasks, and default specs/themes/targets func createBenchmarkConfig(numProjects, numTasks int) Config { config := Config{} // Create projects config.ProjectList = make([]Project, numProjects) for i := 0; i < numProjects; i++ { config.ProjectList[i] = Project{ Name: fmt.Sprintf("project-%d", i), Path: fmt.Sprintf("/path/to/project-%d", i), RelPath: fmt.Sprintf("project-%d", i), Tags: []string{"tag1", "tag2"}, } } // Create tasks config.TaskList = make([]Task, numTasks) for i := 0; i < numTasks; i++ { config.TaskList[i] = Task{ Name: fmt.Sprintf("task-%d", i), Cmd: fmt.Sprintf("echo task %d", i), } } // Create specs config.SpecList = []Spec{ {Name: "default", Output: "stream", Forks: 4}, {Name: "parallel", Output: "stream", Parallel: true, Forks: 8}, } // Create themes config.ThemeList = []Theme{ {Name: "default"}, {Name: "custom"}, } // Create targets config.TargetList = []Target{ {Name: "default", All: true}, {Name: "frontend", Tags: []string{"frontend"}}, } return config } // Lookup_GetProject: Find project by name (O(n) linear search) func BenchmarkLookup_GetProject(b *testing.B) { sizes := []int{10, 50, 100, 500} for _, size := range sizes { b.Run(fmt.Sprintf("projects_%d", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) // Look up a project in the middle targetName := fmt.Sprintf("project-%d", size/2) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetProject(targetName) } }) } } // Lookup_GetTask: Find task by name (O(n) linear search) func BenchmarkLookup_GetTask(b *testing.B) { sizes := []int{10, 50, 100, 500} for _, size := range sizes { b.Run(fmt.Sprintf("tasks_%d", size), func(b *testing.B) { config := createBenchmarkConfig(10, size) // Look up a task in the middle targetName := fmt.Sprintf("task-%d", size/2) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetTask(targetName) } }) } } // Lookup_GetSpec: Find spec by name func BenchmarkLookup_GetSpec(b *testing.B) { config := createBenchmarkConfig(10, 10) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetSpec("default") } } // Lookup_GetTheme: Find theme by name func BenchmarkLookup_GetTheme(b *testing.B) { config := createBenchmarkConfig(10, 10) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetTheme("default") } } // Lookup_GetTarget: Find target by name func BenchmarkLookup_GetTarget(b *testing.B) { config := createBenchmarkConfig(10, 10) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetTarget("default") } } // Filter_ByName: Filter projects by name list func BenchmarkFilter_ByName(b *testing.B) { sizes := []int{10, 50, 100} for _, size := range sizes { b.Run(fmt.Sprintf("projects_%d", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) // Look up 5 projects names := []string{ fmt.Sprintf("project-%d", size/5), fmt.Sprintf("project-%d", size/4), fmt.Sprintf("project-%d", size/3), fmt.Sprintf("project-%d", size/2), fmt.Sprintf("project-%d", size-1), } b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetProjectsByName(names) } }) } } // Filter_ByTags: Filter projects by tags func BenchmarkFilter_ByTags(b *testing.B) { sizes := []int{10, 50, 100, 500} for _, size := range sizes { b.Run(fmt.Sprintf("projects_%d", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) tags := []string{"tag1"} b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetProjectsByTags(tags) } }) } } // Filter_ByPath: Filter by path patterns (simple, *, **) func BenchmarkFilter_ByPath(b *testing.B) { sizes := []int{10, 50, 100} for _, size := range sizes { b.Run(fmt.Sprintf("projects_%d_simple", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) paths := []string{"project-1"} b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetProjectsByPath(paths) } }) b.Run(fmt.Sprintf("projects_%d_glob", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) paths := []string{"project-*"} b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetProjectsByPath(paths) } }) b.Run(fmt.Sprintf("projects_%d_doubleglob", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) paths := []string{"**/project-*"} b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetProjectsByPath(paths) } }) } } // Filter_Combined: FilterProjects with multiple criteria func BenchmarkFilter_Combined(b *testing.B) { sizes := []int{10, 50, 100} for _, size := range sizes { b.Run(fmt.Sprintf("projects_%d_all", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.FilterProjects(false, true, nil, nil, nil, "") } }) b.Run(fmt.Sprintf("projects_%d_bytags", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.FilterProjects(false, false, nil, nil, []string{"tag1"}, "") } }) } } // Util_ConfigLoad: Simulates config loading (ParseTask lookups) func BenchmarkUtil_ConfigLoad(b *testing.B) { taskCounts := []int{10, 25, 50, 100} for _, numTasks := range taskCounts { b.Run(fmt.Sprintf("tasks_%d", numTasks), func(b *testing.B) { config := createBenchmarkConfig(50, numTasks) b.ResetTimer() for i := 0; i < b.N; i++ { // Simulate what happens during config load: // Each task calls GetTheme, GetSpec, GetTarget for j := 0; j < numTasks; j++ { _, _ = config.GetTheme("default") _, _ = config.GetSpec("default") _, _ = config.GetTarget("default") } } }) } } // Lookup_GetCommand: Find task and convert to command func BenchmarkLookup_GetCommand(b *testing.B) { sizes := []int{10, 50, 100, 500} for _, size := range sizes { b.Run(fmt.Sprintf("tasks_%d", size), func(b *testing.B) { config := createBenchmarkConfig(10, size) targetName := fmt.Sprintf("task-%d", size/2) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetCommand(targetName) } }) } } // Filter_ByTagsExpr: Filter using tag expressions (&&, ||, !) func BenchmarkFilter_ByTagsExpr(b *testing.B) { sizes := []int{10, 50, 100} for _, size := range sizes { b.Run(fmt.Sprintf("projects_%d_simple", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetProjectsByTagsExpr("tag1") } }) b.Run(fmt.Sprintf("projects_%d_and", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetProjectsByTagsExpr("tag1 && tag2") } }) b.Run(fmt.Sprintf("projects_%d_or", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetProjectsByTagsExpr("tag1 || tag2") } }) b.Run(fmt.Sprintf("projects_%d_complex", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = config.GetProjectsByTagsExpr("(tag1 && tag2) || !tag3") } }) } } // Util_GetCwdProject: Find project matching current directory func BenchmarkUtil_GetCwdProject(b *testing.B) { sizes := []int{10, 50, 100, 500} for _, size := range sizes { b.Run(fmt.Sprintf("projects_%d", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) b.ResetTimer() for i := 0; i < b.N; i++ { // This will search through all projects // In real usage, it matches against cwd _, _ = config.GetCwdProject() } }) } } // Filter_Intersect: Intersection of project lists func BenchmarkFilter_Intersect(b *testing.B) { sizes := []int{10, 50, 100} for _, size := range sizes { b.Run(fmt.Sprintf("projects_%d", size), func(b *testing.B) { config := createBenchmarkConfig(size, 10) // Create two overlapping project lists list1 := config.ProjectList[:size/2] list2 := config.ProjectList[size/4:] b.ResetTimer() for i := 0; i < b.N; i++ { _ = config.GetIntersectProjects(list1, list2) } }) } } ================================================ FILE: core/dao/common.go ================================================ package dao import ( "fmt" "os" "os/exec" "regexp" "strings" "github.com/gookit/color" "gopkg.in/yaml.v3" "github.com/alajmo/mani/core" ) // Resource Errors type ResourceErrors[T any] struct { Resource *T Errors []error } type Resource interface { GetContext() string GetContextLine() int } func FormatErrors(re Resource, errs []error) error { var msg = "" partsRe := regexp.MustCompile(`line (\d*): (.*)`) context := re.GetContext() var errPrefix = color.FgRed.Sprintf("error") var ptrPrefix = color.FgBlue.Sprintf("-->") for _, err := range errs { match := partsRe.FindStringSubmatch(err.Error()) // In-case matching fails, return unformatted error if len(match) != 3 { contextLine := re.GetContextLine() if contextLine == -1 { msg = fmt.Sprintf("%s%s: %s\n %s %s\n\n", msg, errPrefix, err, ptrPrefix, context) } else { msg = fmt.Sprintf("%s%s: %s\n %s %s:%d\n\n", msg, errPrefix, err, ptrPrefix, context, contextLine) } } else { msg = fmt.Sprintf("%s%s: %s\n %s %s:%s\n\n", msg, errPrefix, match[2], ptrPrefix, context, match[1]) } } if msg != "" { return &core.ConfigErr{Msg: msg} } return nil } // ENV func ParseNodeEnv(node yaml.Node) []string { var envs []string count := len(node.Content) for i := 0; i < count; i += 2 { env := fmt.Sprintf("%v=%v", node.Content[i].Value, node.Content[i+1].Value) envs = append(envs, env) } return envs } func EvaluateEnv(envList []string) ([]string, error) { var envs []string for _, arg := range envList { kv := strings.SplitN(arg, "=", 2) if val, hasPrefix := strings.CutPrefix(kv[1], "$("); hasPrefix { if cmdStr, hasSuffix := strings.CutSuffix(val, ")"); hasSuffix { cmd := exec.Command("sh", "-c", cmdStr) cmd.Env = os.Environ() out, err := cmd.CombinedOutput() if err != nil { return envs, &core.ConfigEnvFailed{Name: kv[0], Err: string(out)} } envs = append(envs, fmt.Sprintf("%v=%v", kv[0], string(out))) continue } } envs = append(envs, fmt.Sprintf("%v=%v", kv[0], kv[1])) } return envs, nil } // MergeEnvs Merges environment variables. // Priority is from highest to lowest (1st env takes precedence over the last entry). func MergeEnvs(envs ...[]string) []string { var mergedEnvs []string args := make(map[string]bool) for _, part := range envs { for _, elem := range part { elem = strings.TrimSuffix(elem, "\n") kv := strings.SplitN(elem, "=", 2) _, ok := args[kv[0]] if !ok { mergedEnvs = append(mergedEnvs, elem) args[kv[0]] = true } } } return mergedEnvs } ================================================ FILE: core/dao/common_test.go ================================================ package dao import ( "testing" "gopkg.in/yaml.v3" ) func TestEnv_ParseNodeEnv(t *testing.T) { tests := []struct { name string input yaml.Node expected []string }{ { name: "basic env variables", input: yaml.Node{ Content: []*yaml.Node{ {Value: "KEY1"}, {Value: "value1"}, {Value: "KEY2"}, {Value: "value2"}, }, }, expected: []string{ "KEY1=value1", "KEY2=value2", }, }, { name: "empty env", input: yaml.Node{ Content: []*yaml.Node{}, }, expected: []string{}, }, { name: "env with special characters", input: yaml.Node{ Content: []*yaml.Node{ {Value: "PATH"}, {Value: "/usr/bin:/bin"}, {Value: "URL"}, {Value: "http://example.com"}, }, }, expected: []string{ "PATH=/usr/bin:/bin", "URL=http://example.com", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := ParseNodeEnv(tt.input) if !equalStringSlices(result, tt.expected) { t.Errorf("expected %v, got %v", tt.expected, result) } }) } } func TestEnv_MergeEnvs(t *testing.T) { tests := []struct { name string inputs [][]string expected []string }{ { name: "basic merge", inputs: [][]string{ {"KEY1=value1", "KEY2=value2"}, {"KEY3=value3"}, }, expected: []string{ "KEY1=value1", "KEY2=value2", "KEY3=value3", }, }, { name: "override priority", inputs: [][]string{ {"KEY1=override"}, {"KEY1=original", "KEY2=value2"}, }, expected: []string{ "KEY1=override", "KEY2=value2", }, }, { name: "empty inputs", inputs: [][]string{ {}, {}, }, expected: []string{}, }, { name: "with newline characters", inputs: [][]string{ {"KEY1=value1\n", "KEY2=value2\n"}, {"KEY3=value3\n"}, }, expected: []string{ "KEY1=value1", "KEY2=value2", "KEY3=value3", }, }, { name: "complex values", inputs: [][]string{ {"PATH=/usr/bin:/bin", "URL=http://example.com"}, {"DEBUG=true", "PATH=/custom/path"}, }, expected: []string{ "PATH=/usr/bin:/bin", "URL=http://example.com", "DEBUG=true", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := MergeEnvs(tt.inputs...) if !equalStringSlices(result, tt.expected) { t.Errorf("expected %v, got %v", tt.expected, result) } }) } } ================================================ FILE: core/dao/config.go ================================================ package dao import ( "fmt" "os" "os/exec" "path/filepath" "strings" "text/template" "github.com/alajmo/mani/core" "github.com/gookit/color" "gopkg.in/yaml.v3" ) var ( DEFAULT_SHELL = "bash -c" DEFAULT_SHELL_PROGRAM = "bash" ACCEPTABLE_FILE_NAMES = []string{"mani.yaml", "mani.yml", ".mani.yaml", ".mani.yml"} DEFAULT_THEME = Theme{ Name: "default", Stream: DefaultStream, Table: DefaultTable, Tree: DefaultTree, TUI: DefaultTUI, Block: DefaultBlock, Color: core.Ptr(true), } DEFAULT_TARGET = Target{ Name: "default", All: false, Cwd: false, Projects: []string{}, Paths: []string{}, Tags: []string{}, TagsExpr: "", } DEFAULT_SPEC = Spec{ Name: "default", Output: "stream", Parallel: false, Forks: 4, IgnoreErrors: false, IgnoreNonExisting: false, OmitEmptyRows: false, OmitEmptyColumns: false, ClearOutput: true, } ) type Config struct { // Internal EnvList []string `yaml:"-"` ImportData []Import `yaml:"-"` ThemeList []Theme `yaml:"-"` SpecList []Spec `yaml:"-"` TargetList []Target `yaml:"-"` ProjectList []Project `yaml:"-"` TaskList []Task `yaml:"-"` Path string `yaml:"-"` Dir string `yaml:"-"` UserConfigFile *string `yaml:"-"` ConfigPaths []string `yaml:"-"` Color bool `yaml:"-"` Shell string `yaml:"shell"` SyncRemotes *bool `yaml:"sync_remotes"` SyncGitignore *bool `yaml:"sync_gitignore"` RemoveOrphanedWorktrees *bool `yaml:"remove_orphaned_worktrees"` ReloadTUI *bool `yaml:"reload_tui_on_change"` // Intermediate Env yaml.Node `yaml:"env"` Import yaml.Node `yaml:"import"` Themes yaml.Node `yaml:"themes"` Specs yaml.Node `yaml:"specs"` Targets yaml.Node `yaml:"targets"` Projects yaml.Node `yaml:"projects"` Tasks yaml.Node `yaml:"tasks"` } func (c *Config) GetContext() string { return c.Path } func (c *Config) GetContextLine() int { return -1 } // Returns the config env list as a string splice in the form [key=value, key1=$(echo 123)] func (c Config) GetEnvList() []string { var envs []string count := len(c.Env.Content) for i := 0; i < count; i += 2 { env := fmt.Sprintf("%v=%v", c.Env.Content[i].Value, c.Env.Content[i+1].Value) envs = append(envs, env) } return envs } func getUserConfigFile(userConfigPath string) *string { // Flag if userConfigPath != "" { if _, err := os.Stat(userConfigPath); err == nil { return &userConfigPath } } // Env val, present := os.LookupEnv("MANI_USER_CONFIG") if present { return &val } // Default defaultUserConfigDir, _ := os.UserConfigDir() defaultUserConfigPath := filepath.Join(defaultUserConfigDir, "mani", "config.yaml") if _, err := os.Stat(defaultUserConfigPath); err == nil { return &defaultUserConfigPath } return nil } // Function to read Mani configs. func ReadConfig(configFilepath string, userConfigPath string, colorFlag bool) (Config, error) { color := CheckUserColor(colorFlag) var configPath string userConfigFile := getUserConfigFile(userConfigPath) // Try to find config file in current directory and all parents if configFilepath != "" { filename, err := filepath.Abs(configFilepath) if err != nil { return Config{}, err } configPath = filename } else { wd, err := os.Getwd() if err != nil { return Config{}, err } // Check first cwd and all parent directories, then if not found, // check if env variable MANI_CONFIG is set, and if not found // return no config found filename, err := core.FindFileInParentDirs(wd, ACCEPTABLE_FILE_NAMES) if err != nil { val, present := os.LookupEnv("MANI_CONFIG") if present { filename = val } else { return Config{}, err } } filename, err = core.ResolveTildePath(filename) if err != nil { return Config{}, err } filename, err = filepath.Abs(filename) if err != nil { return Config{}, err } configPath = filename } dat, err := os.ReadFile(configPath) if err != nil { return Config{}, err } // Found config, now try to read it var config Config config.Path = configPath config.Dir = filepath.Dir(configPath) config.UserConfigFile = userConfigFile config.Color = color err = yaml.Unmarshal(dat, &config) if err != nil { re := ResourceErrors[Config]{Resource: &config, Errors: []error{err}} return config, FormatErrors(re.Resource, re.Errors) } // Set default shell command if config.Shell == "" { config.Shell = DEFAULT_SHELL } else { config.Shell = core.FormatShell(config.Shell) } // Set Sync Gitignore if config.SyncGitignore == nil { config.SyncGitignore = core.Ptr(true) } // Set Reload TUI if config.ReloadTUI == nil { config.ReloadTUI = core.Ptr(false) } // Set Sync Remote if config.SyncRemotes == nil { config.SyncRemotes = core.Ptr(false) } // Set Remove Orphan Worktrees if config.RemoveOrphanedWorktrees == nil { config.RemoveOrphanedWorktrees = core.Ptr(false) } configResources, err := config.importConfigs() if err != nil { return config, err } config.TaskList = configResources.Tasks config.ProjectList = configResources.Projects config.ThemeList = configResources.Themes config.SpecList = configResources.Specs config.TargetList = configResources.Targets config.EnvList = configResources.Envs config.CheckConfigNoColor() for _, configPath := range configResources.Imports { config.ConfigPaths = append(config.ConfigPaths, configPath.Path) } // Set default theme if it's not set already _, err = config.GetTheme(DEFAULT_THEME.Name) if err != nil { config.ThemeList = append(config.ThemeList, DEFAULT_THEME) } // Set default spec if it's not set already _, err = config.GetSpec(DEFAULT_SPEC.Name) if err != nil { config.SpecList = append(config.SpecList, DEFAULT_SPEC) } // Set default target if it's not set already _, err = config.GetTarget(DEFAULT_TARGET.Name) if err != nil { config.TargetList = append(config.TargetList, DEFAULT_TARGET) } // Parse all tasks taskErrors := make([]ResourceErrors[Task], len(configResources.Tasks)) for i := range configResources.Tasks { taskErrors[i].Resource = &configResources.Tasks[i] configResources.Tasks[i].ParseTask(config, &taskErrors[i]) } var configErr = "" for _, taskError := range taskErrors { if len(taskError.Errors) > 0 { configErr = fmt.Sprintf("%s%s", configErr, FormatErrors(taskError.Resource, taskError.Errors)) } } if configErr != "" { return config, &core.ConfigErr{Msg: configErr} } return config, nil } // Open mani config in editor func (c Config) EditConfig() error { return openEditor(c.Path, -1) } func openEditor(path string, lineNr int) error { editor := os.Getenv("EDITOR") var args []string if lineNr > 0 { switch editor { case "nvim": args = []string{fmt.Sprintf("+%v", lineNr), path} case "vim": args = []string{fmt.Sprintf("+%v", lineNr), path} case "vi": args = []string{fmt.Sprintf("+%v", lineNr), path} case "emacs": args = []string{fmt.Sprintf("+%v", lineNr), path} case "nano": args = []string{fmt.Sprintf("+%v", lineNr), path} case "code": // visual studio code args = []string{"--goto", fmt.Sprintf("%s:%v", path, lineNr)} case "idea": // Intellij args = []string{"--line", fmt.Sprintf("%v", lineNr), path} case "subl": // Sublime args = []string{fmt.Sprintf("%s:%v", path, lineNr)} case "atom": args = []string{fmt.Sprintf("%s:%v", path, lineNr)} case "notepad-plus-plus": args = []string{"-n", fmt.Sprintf("%v", lineNr), path} default: args = []string{path} } } else { args = []string{path} } cmd := exec.Command(editor, args...) cmd.Env = os.Environ() cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err := cmd.Run() if err != nil { return err } return nil } // Open mani config in editor and optionally go to line matching the task name func (c Config) EditTask(name string) error { configPath := c.Path if name != "" { task, err := c.GetTask(name) if err != nil { return err } configPath = task.context } dat, err := os.ReadFile(configPath) if err != nil { return err } type ConfigTmp struct { Tasks yaml.Node } var configTmp ConfigTmp err = yaml.Unmarshal([]byte(dat), &configTmp) if err != nil { return err } lineNr := 0 if name == "" { lineNr = configTmp.Tasks.Line - 1 } else { for _, task := range configTmp.Tasks.Content { if task.Value == name { lineNr = task.Line break } } } return openEditor(configPath, lineNr) } // Open mani config in editor and optionally go to line matching the project name func (c Config) EditProject(name string) error { configPath := c.Path if name != "" { project, err := c.GetProject(name) if err != nil { return err } configPath = project.context } dat, err := os.ReadFile(configPath) if err != nil { return err } type ConfigTmp struct { Projects yaml.Node } var configTmp ConfigTmp err = yaml.Unmarshal([]byte(dat), &configTmp) if err != nil { return err } lineNr := 0 if name == "" { lineNr = configTmp.Projects.Line - 1 } else { for _, project := range configTmp.Projects.Content { if project.Value == name { lineNr = project.Line break } } } return openEditor(configPath, lineNr) } func InitMani(args []string, initFlags core.InitFlags) ([]Project, error) { // Choose to initialize mani in a different directory // 1. absolute or // 2. relative or // 3. working directory var configDir string if len(args) > 0 && filepath.IsAbs(args[0]) { // absolute path configDir = args[0] } else if len(args) > 0 { // relative path wd, err := os.Getwd() if err != nil { return []Project{}, err } configDir = filepath.Join(wd, args[0]) } else { // working directory wd, err := os.Getwd() if err != nil { return []Project{}, err } configDir = wd } err := os.MkdirAll(configDir, os.ModePerm) if err != nil { return []Project{}, err } configPath := filepath.Join(configDir, "mani.yaml") if _, err := os.Stat(configPath); err == nil { return []Project{}, &core.AlreadyManiDirectory{Dir: configDir} } // Check if current directory is a git repository gitDir := filepath.Join(configDir, ".git") isGitRepo := false if _, err := os.Stat(gitDir); err == nil { isGitRepo = true } var projects []Project // Only add root directory as project if it IS a git repository if isGitRepo { url, err := core.GetWdRemoteURL(configDir) if err != nil { return []Project{}, err } rootName := filepath.Base(configDir) rootPath := "." rootURL := url rootProject := Project{Name: rootName, Path: rootPath, URL: rootURL} // Discover worktrees for root project if initFlags.AutoDiscovery { worktrees, _ := core.GetWorktreeList(configDir) for wtPath, branch := range worktrees { if branch == "" { continue } wtRelPath, _ := filepath.Rel(configDir, wtPath) rootProject.WorktreeList = append(rootProject.WorktreeList, Worktree{ Path: wtRelPath, Branch: branch, }) } } projects = []Project{rootProject} } if initFlags.AutoDiscovery { prs, err := FindVCSystems(configDir) if err != nil { return []Project{}, err } RenameDuplicates(prs) projects = append(projects, prs...) } funcMap := template.FuncMap{ "projectItem": func(name string, path string, url string, worktrees []Worktree) string { var txt = name + ":" if name != path { txt = txt + "\n path: " + path } if url != "" { txt = txt + "\n url: " + url } if len(worktrees) > 0 { txt = txt + "\n worktrees:" for _, wt := range worktrees { txt = txt + "\n - path: " + wt.Path txt = txt + "\n branch: " + wt.Branch } } return txt }, } tmpl, err := template.New("init").Funcs(funcMap).Parse(`projects: {{- range .}} {{ (projectItem .Name .Path .URL .WorktreeList) }} {{ end }} tasks: hello: desc: Print Hello World cmd: echo "Hello World" `, ) if err != nil { return []Project{}, err } // Create mani.yaml f, err := os.Create(configPath) if err != nil { return []Project{}, err } err = tmpl.Execute(f, projects) if err != nil { return []Project{}, err } err = f.Close() if err != nil { return []Project{}, err } // Update gitignore file only if inside a git repository hasURL := false for _, project := range projects { if project.URL != "" { hasURL = true break } } if isGitRepo && hasURL && initFlags.SyncGitignore { // Add gitignore file gitignoreFilepath := filepath.Join(configDir, ".gitignore") if _, err := os.Stat(gitignoreFilepath); os.IsNotExist(err) { err := os.WriteFile(gitignoreFilepath, []byte(""), 0644) if err != nil { return []Project{}, err } } var projectNames []string for _, project := range projects { if project.URL == "" { continue } if project.Path == "." { continue } projectNames = append(projectNames, project.Path) } // Add projects to gitignore file err = UpdateProjectsToGitignore(projectNames, gitignoreFilepath) if err != nil { return []Project{}, err } } fmt.Println("\nInitialized mani repository in", configDir) fmt.Println("- Created mani.yaml") if isGitRepo && hasURL && initFlags.SyncGitignore { fmt.Println("- Created .gitignore") } return projects, nil } func RenameDuplicates(projects []Project) { projectNamesCount := make(map[string]int) // Find duplicate names for _, p := range projects { projectNamesCount[p.Name] += 1 } // Rename duplicate projects for i, p := range projects { if projectNamesCount[p.Name] > 1 { projects[i].Name = p.Path } } } func CheckUserColor(colorFlag bool) bool { _, present := os.LookupEnv("NO_COLOR") if present || !colorFlag { color.Disable() return false } return true } func (c *Config) CheckConfigNoColor() { for _, env := range c.EnvList { name := strings.Split(env, "=")[0] if name == "NO_COLOR" { color.Disable() } } } ================================================ FILE: core/dao/config_test.go ================================================ package dao import ( "testing" ) func TestConfig_DuplicateProjectName(t *testing.T) { originalProjects := []Project{ {Name: "project-a", Path: "sub-1/project-a"}, {Name: "project-a", Path: "sub-2/project-a"}, {Name: "project-b", Path: "sub-3/project-b"}, } var projects []Project projects = append(projects, originalProjects...) RenameDuplicates(projects) if projects[0].Name != originalProjects[0].Path { t.Fatalf(`Wanted: %q, Found: %q`, projects[0].Path, originalProjects[0].Name) } if projects[1].Name != originalProjects[1].Path { t.Fatalf(`Wanted: %q, Found: %q`, projects[1].Path, originalProjects[1].Name) } if originalProjects[2].Name != projects[2].Name { t.Fatalf(`Wanted: %q, Found: %q`, projects[2].Name, originalProjects[2].Name) } } ================================================ FILE: core/dao/import.go ================================================ package dao import ( "fmt" "os" "path/filepath" "github.com/alajmo/mani/core" "github.com/gookit/color" "gopkg.in/yaml.v3" ) type Import struct { Path string context string contextLine int } func (i *Import) GetContext() string { return i.context } func (i *Import) GetContextLine() int { return i.contextLine } // Populates SpecList and creates a default spec if no default spec is set. func (c *Config) GetImportList() ([]Import, []ResourceErrors[Import]) { var imports []Import count := len(c.Import.Content) importErrors := []ResourceErrors[Import]{} foundErrors := false for i := range count { imp := &Import{ Path: c.Import.Content[i].Value, context: c.Path, contextLine: c.Import.Content[i].Line, } imports = append(imports, *imp) } if foundErrors { return imports, importErrors } return imports, nil } // Used for config imports type ConfigResources struct { Imports []Import Themes []Theme Specs []Spec Targets []Target Tasks []Task Projects []Project Envs []string ThemeErrors []ResourceErrors[Theme] SpecErrors []ResourceErrors[Spec] TargetErrors []ResourceErrors[Target] TaskErrors []ResourceErrors[Task] ProjectErrors []ResourceErrors[Project] ImportErrors []ResourceErrors[Import] } type Node struct { Path string Imports []Import Visiting bool Visited bool } type NodeLink struct { A Node B Node } type FoundCyclicDependency struct { Cycles []NodeLink } func (c *FoundCyclicDependency) Error() string { var msg string var errPrefix = color.FgRed.Sprintf("error") var ptrPrefix = color.FgBlue.Sprintf("-->") msg = fmt.Sprintf("%s: %s\n", errPrefix, "Found direct or indirect circular dependency") for i := range c.Cycles { msg += fmt.Sprintf(" %s %s\n %s\n", ptrPrefix, c.Cycles[i].A.Path, c.Cycles[i].B.Path) } return msg } // Given config imports, use a Depth-first-search algorithm to recursively // check for resources (tasks, projects, dirs, themes, specs, targets). // A struct is passed around that is populated with resources from each config. // In case a cyclic dependency is found (a -> b and b -> a), we return early and // with an error containing the cyclic dependency found. // // This is the first parsing, later on we will perform more passes where we check what commands/tasks // are imported. func (c Config) importConfigs() (ConfigResources, error) { // Main config ci := ConfigResources{} c.loadResources(&ci) if c.UserConfigFile != nil { ci.Imports = append(ci.Imports, Import{Path: *c.UserConfigFile, context: c.Path, contextLine: -1}) } // Import other configs n := Node{ Path: c.Path, Imports: ci.Imports, } m := make(map[string]*Node) m[n.Path] = &n cycles := []NodeLink{} dfs(&n, m, &cycles, &ci) // Get errors configErr := concatErrors(ci, &cycles) if configErr != nil { return ci, configErr } return ci, nil } func concatErrors(ci ConfigResources, cycles *[]NodeLink) error { var configErr = "" if len(*cycles) > 0 { err := &FoundCyclicDependency{Cycles: *cycles} configErr = fmt.Sprintf("%s%s\n", configErr, err.Error()) } for _, theme := range ci.ThemeErrors { if len(theme.Errors) > 0 { configErr = fmt.Sprintf("%s%s", configErr, FormatErrors(theme.Resource, theme.Errors)) } } for _, spec := range ci.SpecErrors { if len(spec.Errors) > 0 { configErr = fmt.Sprintf("%s%s", configErr, FormatErrors(spec.Resource, spec.Errors)) } } for _, target := range ci.TargetErrors { if len(target.Errors) > 0 { configErr = fmt.Sprintf("%s%s", configErr, FormatErrors(target.Resource, target.Errors)) } } for _, task := range ci.TaskErrors { if len(task.Errors) > 0 { configErr = fmt.Sprintf("%s%s", configErr, FormatErrors(task.Resource, task.Errors)) } } for _, project := range ci.ProjectErrors { if len(project.Errors) > 0 { configErr = fmt.Sprintf("%s%s", configErr, FormatErrors(project.Resource, project.Errors)) } } for _, imp := range ci.ImportErrors { if len(imp.Errors) > 0 { configErr = fmt.Sprintf("%s%s", configErr, FormatErrors(imp.Resource, imp.Errors)) } } if configErr != "" { return &core.ConfigErr{Msg: configErr} } return nil } func parseConfig(path string, ci *ConfigResources) ([]Import, error) { dat, err := os.ReadFile(path) if err != nil { return []Import{}, err } absPath, err := filepath.Abs(path) if err != nil { return []Import{}, err } // Found config, now try to read it var config Config err = yaml.Unmarshal(dat, &config) if err != nil { return []Import{}, err } config.Path = absPath config.Dir = filepath.Dir(absPath) imports := config.loadResources(ci) return imports, nil } func (c Config) loadResources(ci *ConfigResources) []Import { imports, importErrors := c.GetImportList() ci.ImportErrors = append(ci.ImportErrors, importErrors...) tasks, taskErrors := c.GetTaskList() ci.TaskErrors = append(ci.TaskErrors, taskErrors...) projects, projectErrors := c.GetProjectList() ci.ProjectErrors = append(ci.ProjectErrors, projectErrors...) themes, themeErrors := c.ParseThemes() ci.ThemeErrors = append(ci.ThemeErrors, themeErrors...) specs, specErrors := c.GetSpecList() ci.SpecErrors = append(ci.SpecErrors, specErrors...) targets, targetErrors := c.GetTargetList() ci.TargetErrors = append(ci.TargetErrors, targetErrors...) envs := c.GetEnvList() ci.Imports = append(ci.Imports, imports...) ci.Tasks = append(ci.Tasks, tasks...) ci.Projects = append(ci.Projects, projects...) ci.Themes = append(ci.Themes, themes...) ci.Specs = append(ci.Specs, specs...) ci.Targets = append(ci.Targets, targets...) ci.Envs = append(ci.Envs, envs...) return imports } func dfs(n *Node, m map[string]*Node, cycles *[]NodeLink, ci *ConfigResources) { n.Visiting = true for i := range n.Imports { p, err := core.GetAbsolutePath(filepath.Dir(n.Path), n.Imports[i].Path, "") if err != nil { importError := ResourceErrors[Import]{Resource: &n.Imports[i], Errors: core.StringsToErrors(err.(*yaml.TypeError).Errors)} ci.ImportErrors = append(ci.ImportErrors, importError) continue } // Skip visited nodes var nc Node v, exists := m[p] if exists { nc = *v } else { nc = Node{Path: p} m[nc.Path] = &nc } if nc.Visited { continue } // Found cyclic dependency if nc.Visiting { c := NodeLink{ A: *n, B: nc, } *cycles = append(*cycles, c) break } // Import Config imports, err := parseConfig(nc.Path, ci) if err != nil { importError := ResourceErrors[Import]{Resource: &n.Imports[i], Errors: []error{err}} ci.ImportErrors = append(ci.ImportErrors, importError) continue } nc.Imports = imports dfs(&nc, m, cycles, ci) // err = dfs(&nc, m, cycles, ci) // if err != nil { // return err // } } n.Visiting = false n.Visited = true } ================================================ FILE: core/dao/project.go ================================================ package dao import ( "bufio" "container/list" "fmt" "os" "path/filepath" "regexp" "slices" "strings" "gopkg.in/yaml.v3" "github.com/alajmo/mani/core" ) type Project struct { Name string `yaml:"name"` Path string `yaml:"path"` Desc string `yaml:"desc"` URL string `yaml:"url"` Clone string `yaml:"clone"` Branch string `yaml:"branch"` SingleBranch *bool `yaml:"single_branch"` Sync *bool `yaml:"sync"` Tags []string `yaml:"tags"` EnvList []string `yaml:"-"` RemoteList []Remote `yaml:"-"` Env yaml.Node `yaml:"env"` Remotes yaml.Node `yaml:"remotes"` Worktrees yaml.Node `yaml:"worktrees"` WorktreeList []Worktree `yaml:"-"` context string contextLine int RelPath string } type Remote struct { Name string URL string } type Worktree struct { Path string `yaml:"path"` Branch string `yaml:"branch"` } func (p *Project) GetContext() string { return p.context } func (p *Project) GetContextLine() int { return p.contextLine } func (p Project) IsSingleBranch() bool { return p.SingleBranch != nil && *p.SingleBranch } func (p Project) IsSync() bool { return p.Sync == nil || *p.Sync } func (p Project) GetValue(key string, _ int) string { switch strings.ToLower(key) { case "project": return p.Name case "path": return p.Path case "relpath": return p.RelPath case "desc", "description": return p.Desc case "url": return p.URL case "tag", "tags": return strings.Join(p.Tags, ", ") case "worktree", "worktrees": if len(p.WorktreeList) == 0 { return "" } entries := make([]string, len(p.WorktreeList)) for i, wt := range p.WorktreeList { entries[i] = wt.Path + ":" + wt.Branch } return strings.Join(entries, ", ") default: return "" } } func (c *Config) GetProjectList() ([]Project, []ResourceErrors[Project]) { var projects []Project count := len(c.Projects.Content) projectErrors := []ResourceErrors[Project]{} foundErrors := false for i := 0; i < count; i += 2 { project := &Project{ context: c.Path, contextLine: c.Projects.Content[i].Line, } err := c.Projects.Content[i+1].Decode(project) if err != nil { foundErrors = true projectError := ResourceErrors[Project]{Resource: project, Errors: core.StringsToErrors(err.(*yaml.TypeError).Errors)} projectErrors = append(projectErrors, projectError) continue } project.Name = c.Projects.Content[i].Value // Add absolute and relative path for each project project.Path, err = core.GetAbsolutePath(c.Dir, project.Path, project.Name) if err != nil { foundErrors = true projectError := ResourceErrors[Project]{Resource: project, Errors: core.StringsToErrors(err.(*yaml.TypeError).Errors)} projectErrors = append(projectErrors, projectError) continue } project.RelPath, err = core.GetRelativePath(c.Dir, project.Path) if err != nil { foundErrors = true projectError := ResourceErrors[Project]{Resource: project, Errors: core.StringsToErrors(err.(*yaml.TypeError).Errors)} projectErrors = append(projectErrors, projectError) continue } envList := []string{} projectEnvs, err := EvaluateEnv(ParseNodeEnv(project.Env)) if err != nil { foundErrors = true projectError := ResourceErrors[Project]{Resource: project, Errors: core.StringsToErrors(err.(*yaml.TypeError).Errors)} projectErrors = append(projectErrors, projectError) continue } envList = append(envList, projectEnvs...) project.EnvList = envList projectRemotes := ParseRemotes(project.Remotes) project.RemoteList = projectRemotes projectWorktrees, err := ParseWorktrees(project.Worktrees) if err != nil { foundErrors = true projectError := ResourceErrors[Project]{Resource: project, Errors: []error{err}} projectErrors = append(projectErrors, projectError) continue } project.WorktreeList = projectWorktrees projects = append(projects, *project) } if foundErrors { return projects, projectErrors } return projects, nil } // GetFilteredProjects retrieves a filtered list of projects based on the provided ProjectFlags. // It processes various filtering criteria and returns the matching projects. // // The function follows these steps: // 1. If a target is specified, loads the target configuration, otherwise sets all to false // 2. Merges any provided flag values with the target configuration // 3. Applies all filtering criteria using FilterProjects func (c Config) GetFilteredProjects(flags *core.ProjectFlags) ([]Project, error) { var err error var projects []Project target := &Target{} if flags.Target != "" { target, err = c.GetTarget(flags.Target) if err != nil { return []Project{}, err } } if len(flags.Projects) > 0 { target.Projects = flags.Projects } if len(flags.Paths) > 0 { target.Paths = flags.Paths } if len(flags.Tags) > 0 { target.Tags = flags.Tags } if flags.TagsExpr != "" { target.TagsExpr = flags.TagsExpr } if flags.Cwd { target.Cwd = flags.Cwd } if flags.All { target.All = flags.All } projects, err = c.FilterProjects( target.Cwd, target.All, target.Projects, target.Paths, target.Tags, target.TagsExpr, ) if err != nil { return []Project{}, err } return projects, nil } // FilterProjects filters the project list based on various criteria. It supports filtering by: // - All projects (allProjectsFlag) // - Current working directory (cwdFlag) // - Project names (projectsFlag) // - Project paths (projectPathsFlag) // - Project tags (tagsFlag) // - Tag expressions (tagsExprFlag) // // Priority handling: // - If cwdFlag is true, the function immediately returns only the current working directory // project, ignoring all other filters. // - For all other combinations of filters, the function collects projects from each filter // into separate slices, then finds their intersection. If multiple // filters are specified, only projects that match ALL filters will be returned. func (c Config) FilterProjects( cwdFlag bool, allProjectsFlag bool, projectsFlag []string, projectPathsFlag []string, tagsFlag []string, tagsExprFlag string, ) ([]Project, error) { var finalProjects []Project var err error var inputProjects [][]Project if cwdFlag { var cwdProjects []Project cwdProject, err := c.GetCwdProject() cwdProjects = append(cwdProjects, cwdProject) if err != nil { return []Project{}, err } return cwdProjects, nil } if allProjectsFlag { inputProjects = append(inputProjects, c.ProjectList) } if len(projectsFlag) > 0 { var projects []Project projects, err = c.GetProjectsByName(projectsFlag) if err != nil { return []Project{}, err } inputProjects = append(inputProjects, projects) } if len(projectPathsFlag) > 0 { var projectPaths []Project projectPaths, err = c.GetProjectsByPath(projectPathsFlag) if err != nil { return []Project{}, err } inputProjects = append(inputProjects, projectPaths) } if len(tagsFlag) > 0 { var tagProjects []Project tagProjects, err = c.GetProjectsByTags(tagsFlag) if err != nil { return []Project{}, err } inputProjects = append(inputProjects, tagProjects) } if tagsExprFlag != "" { var tagExprProjects []Project tagExprProjects, err = c.GetProjectsByTagsExpr(tagsExprFlag) if err != nil { return []Project{}, err } inputProjects = append(inputProjects, tagExprProjects) } finalProjects = c.GetIntersectProjects(inputProjects...) return finalProjects, nil } func (c Config) GetProject(name string) (*Project, error) { for _, project := range c.ProjectList { if name == project.Name { return &project, nil } } return nil, &core.ProjectNotFound{Name: []string{name}} } func (c Config) GetProjectsByName(projectNames []string) ([]Project, error) { var matchedProjects []Project foundProjectNames := make(map[string]bool) for _, p := range projectNames { foundProjectNames[p] = false } for _, v := range projectNames { for _, p := range c.ProjectList { if v == p.Name { foundProjectNames[p.Name] = true matchedProjects = append(matchedProjects, p) } } } nonExistingProjects := []string{} for k, v := range foundProjectNames { if !v { nonExistingProjects = append(nonExistingProjects, k) } } if len(nonExistingProjects) > 0 { return []Project{}, &core.ProjectNotFound{Name: nonExistingProjects} } return matchedProjects, nil } // GetProjectsByPath Projects must have all dirs to match. // If user provides a path which does not exist, then return error containing // all the paths it didn't find. // Supports glob patterns: // - '*' matches any sequence of non-separator characters // - '**' matches any sequence of characters including separators func (c Config) GetProjectsByPath(dirs []string) ([]Project, error) { if len(dirs) == 0 { return c.ProjectList, nil } foundDirs := make(map[string]bool) for _, dir := range dirs { foundDirs[dir] = false } projects := []Project{} for _, project := range c.ProjectList { // Variable use to check that all dirs are matched var numMatched = 0 for _, dir := range dirs { matchPath := func(dir string, path string) (bool, error) { // Handle glob pattern if strings.Contains(dir, "*") { // Handle '**' glob pattern if strings.Contains(dir, "**") { // Convert the glob pattern to a regex pattern regexPattern := strings.ReplaceAll(dir, "**/", "") regexPattern = strings.ReplaceAll(regexPattern, "*", "[^/]*") regexPattern = strings.ReplaceAll(regexPattern, "?", ".") regexPattern = strings.ReplaceAll(regexPattern, "", "(.*/)*") regexPattern = "^" + regexPattern + "$" matched, err := regexp.MatchString(regexPattern, path) if err != nil { return false, err } if matched { return true, nil } } // Handle standard glob pattern matched, err := filepath.Match(dir, path) if err != nil { return false, err } if matched { return true, nil } } // Try matching as a partial path if strings.Contains(path, dir) { return true, nil } return false, nil } matched, err := matchPath(dir, project.RelPath) if err != nil { return []Project{}, err } if matched { foundDirs[dir] = true numMatched++ } } if numMatched == len(dirs) { projects = append(projects, project) } } nonExistingDirs := []string{} for k, v := range foundDirs { if !v { nonExistingDirs = append(nonExistingDirs, k) } } if len(nonExistingDirs) > 0 { return []Project{}, &core.DirNotFound{Dirs: nonExistingDirs} } return projects, nil } // GetProjectsByTags Projects must have all tags to match. For instance, if --tags frontend,backend // is passed, then a project must have both tags. // We only return error if the flags provided do not exist in the mani config. func (c Config) GetProjectsByTags(tags []string) ([]Project, error) { if len(tags) == 0 { return c.ProjectList, nil } foundTags := make(map[string]bool) for _, tag := range tags { foundTags[tag] = false } // Find projects matching the flag var projects []Project for _, project := range c.ProjectList { // Variable use to check that all tags are matched var numMatched = 0 for _, tag := range tags { for _, projectTag := range project.Tags { if projectTag == tag { foundTags[tag] = true numMatched = numMatched + 1 } } } if numMatched == len(tags) { projects = append(projects, project) } } nonExistingTags := []string{} for k, v := range foundTags { if !v { nonExistingTags = append(nonExistingTags, k) } } if len(nonExistingTags) > 0 { return []Project{}, &core.TagNotFound{Tags: nonExistingTags} } return projects, nil } // GetProjectsByTagsExpr Projects must have all tags to match. For instance, if --tags frontend,backend // is passed, then a project must have both tags. // We only return error if the tags provided do not exist. func (c Config) GetProjectsByTagsExpr(tagsExpr string) ([]Project, error) { if tagsExpr == "" { return c.ProjectList, nil } var projects []Project for _, project := range c.ProjectList { matches, err := evaluateExpression(&project, tagsExpr) if err != nil { return c.ProjectList, &core.TagExprInvalid{Expression: err.Error()} } if matches { projects = append(projects, project) } } return projects, nil } func (c Config) GetCwdProject() (Project, error) { cwd, err := os.Getwd() if err != nil { return Project{}, err } var project Project parts := strings.Split(cwd, string(os.PathSeparator)) out: for i := len(parts) - 1; i >= 0; i-- { p := strings.Join(parts[0:i+1], string(os.PathSeparator)) for _, pro := range c.ProjectList { if p == pro.Path { project = pro break out } } } return project, nil } /** * GetProjectPaths For each project path, get all the enumerations of dirnames. * Example: * Input: * - /frontend/tools/project-a * - /frontend/tools/project-b * - /frontend/tools/node/project-c * - /backend/project-d * Output: * - /frontend * - /frontend/tools * - /frontend/tools/node * - /backend */ func (c Config) GetProjectPaths() []string { dirs := []string{} for _, project := range c.ProjectList { // Ignore projects outside of mani.yaml directory if strings.Contains(project.Path, c.Dir) { ps := strings.Split(filepath.Dir(project.RelPath), string(os.PathSeparator)) for i := 1; i <= len(ps); i++ { p := filepath.Join(ps[0:i]...) if p != "." && !slices.Contains(dirs, p) { dirs = append(dirs, p) } } } } return dirs } func (c Config) GetProjectNames() []string { names := []string{} for _, project := range c.ProjectList { names = append(names, project.Name) } return names } func (c Config) GetProjectUrls() []string { urls := []string{} for _, project := range c.ProjectList { if project.URL != "" { urls = append(urls, project.URL) } } return urls } func (c Config) GetProjectsTree(dirs []string, tags []string) ([]TreeNode, error) { dirProjects, err := c.GetProjectsByPath(dirs) if err != nil { return []TreeNode{}, err } tagProjects, err := c.GetProjectsByTags(tags) if err != nil { return []TreeNode{}, err } projects := c.GetIntersectProjects(dirProjects, tagProjects) var projectPaths = []TNode{} for _, p := range projects { node := TNode{Name: p.Name, Path: p.RelPath} projectPaths = append(projectPaths, node) } var tree []TreeNode for i := range projectPaths { tree = AddToTree(tree, projectPaths[i]) } return tree, nil } // IsGitWorktree checks if the given path is a git worktree (not the main repo). // // A worktree's .git is a FILE (not directory) containing: // "gitdir: /path/to/main-repo/.git/worktrees/worktree-name" func IsGitWorktree(path string) (bool, error) { gitPath := filepath.Join(path, ".git") info, err := os.Stat(gitPath) if err != nil { return false, err } // If .git is a directory, it's a regular git repo (or the main worktree) if info.IsDir() { return false, nil } // .git is a file - read its content content, err := os.ReadFile(gitPath) if err != nil { return false, err } // Parse "gitdir: " contentStr := strings.TrimSpace(string(content)) gitDir, found := strings.CutPrefix(contentStr, "gitdir: ") if !found { return false, nil } // Make gitDir absolute if it's relative if !filepath.IsAbs(gitDir) { gitDir = filepath.Join(path, gitDir) gitDir = filepath.Clean(gitDir) } // Check if it matches the worktree pattern: /.git/worktrees/ sep := string(filepath.Separator) pattern := sep + ".git" + sep + "worktrees" + sep if strings.Contains(gitDir, pattern) { return true, nil } return false, nil } func FindVCSystems(rootPath string) ([]Project, error) { projects := []Project{} err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } // Is file if !info.IsDir() { return nil } if path == rootPath { return nil } // Is Directory and Has a Git Dir inside, add to projects and SkipDir gitDir := filepath.Join(path, ".git") if _, err := os.Stat(gitDir); !os.IsNotExist(err) { name := filepath.Base(path) relPath, _ := filepath.Rel(rootPath, path) // Check if this is a worktree (skip worktrees, they belong to parent) isWorktree, _ := IsGitWorktree(path) if isWorktree { return filepath.SkipDir } // This is a regular repository var project Project url, rErr := core.GetRemoteURL(path) if rErr != nil { project = Project{Name: name, Path: relPath} } else { project = Project{Name: name, Path: relPath, URL: url} } // Get worktrees using git worktree list (skip detached HEAD worktrees) worktrees, _ := core.GetWorktreeList(path) for wtPath, branch := range worktrees { if branch == "" { continue } // Convert absolute path to relative path from project wtRelPath, _ := filepath.Rel(path, wtPath) project.WorktreeList = append(project.WorktreeList, Worktree{ Path: wtRelPath, Branch: branch, }) } projects = append(projects, project) return filepath.SkipDir } return nil }) if err != nil { return projects, err } return projects, nil } func UpdateProjectsToGitignore(projectNames []string, gitignoreFilename string) (err error) { l := list.New() gitignoreFile, err := os.OpenFile(gitignoreFilename, os.O_RDWR, 0644) if err != nil { return &core.FailedToOpenFile{Name: gitignoreFilename} } defer func() { closeErr := gitignoreFile.Close() if err == nil { err = closeErr } }() scanner := bufio.NewScanner(gitignoreFile) for scanner.Scan() { line := scanner.Text() l.PushBack(line) } const maniComment = "# mani #" var insideComment = false var beginElement *list.Element var endElement *list.Element var next *list.Element // Remove all projects inside # mani # for e := l.Front(); e != nil; e = next { next = e.Next() if e.Value == maniComment && !insideComment { insideComment = true beginElement = e continue } if e.Value == maniComment { endElement = e break } if insideComment { l.Remove(e) } } // If missing start # mani # if beginElement == nil { l.PushBack(maniComment) beginElement = l.Back() } // If missing ending # mani # if endElement == nil { l.PushBack(maniComment) } // Insert projects within # mani # section for _, projectName := range projectNames { l.InsertAfter(projectName, beginElement) } err = gitignoreFile.Truncate(0) if err != nil { return err } _, err = gitignoreFile.Seek(0, 0) if err != nil { return err } // Write to gitignore file for e := l.Front(); e != nil; e = e.Next() { str := fmt.Sprint(e.Value) _, err = gitignoreFile.WriteString(str) if err != nil { return err } _, err = gitignoreFile.WriteString("\n") if err != nil { return err } } return nil } // ParseRemotes List of remotes (key: value) func ParseRemotes(node yaml.Node) []Remote { var remotes []Remote count := len(node.Content) for i := 0; i < count; i += 2 { remote := Remote{ Name: node.Content[i].Value, URL: node.Content[i+1].Value, } remotes = append(remotes, remote) } return remotes } // ParseWorktrees parses worktree definitions from YAML func ParseWorktrees(node yaml.Node) ([]Worktree, error) { var worktrees []Worktree for _, content := range node.Content { var wt Worktree if err := content.Decode(&wt); err != nil { return nil, err } // Path is required if wt.Path == "" { return nil, &core.WorktreePathRequired{} } // Default branch to path basename (like git does) if wt.Branch == "" { wt.Branch = filepath.Base(wt.Path) } worktrees = append(worktrees, wt) } return worktrees, nil } func (c Config) GetIntersectProjects(ps ...[]Project) []Project { counts := make(map[string]int, len(c.ProjectList)) for _, projects := range ps { for _, project := range projects { counts[project.Name] += 1 } } var projects []Project for _, p := range c.ProjectList { if counts[p.Name] == len(ps) && len(ps) > 0 { projects = append(projects, p) } } return projects } // TREE type TNode struct { Name string Path string } type TreeNode struct { Path string ProjectName string Children []TreeNode } // AddToTree recursively builds a tree structure from path components // root: The current level of tree nodes // node: Node containing path and name information to be added func AddToTree(root []TreeNode, node TNode) []TreeNode { // Return if path is empty or starts with separator items := strings.Split(node.Path, string(os.PathSeparator)) if len(items) == 0 || items[0] == "" { return root } if len(items) > 0 { var i int // Search for existing node with same path at current level for i = 0; i < len(root); i++ { if root[i].Path == items[0] { // already in tree break } } // If node doesn't exist at current level, create new node if i == len(root) { root = append(root, TreeNode{ Path: items[0], ProjectName: "", Children: []TreeNode{}, }) } // If this is the last component in the path (leaf node/file) if len(items) == 1 { root[i].ProjectName = node.Name // Set name for projects only } else { root[i].ProjectName = "" str := strings.Join(items[1:], string(os.PathSeparator)) n := TNode{Name: node.Name, Path: str} root[i].Children = AddToTree(root[i].Children, n) } } return root } ================================================ FILE: core/dao/project_test.go ================================================ package dao import ( "testing" "gopkg.in/yaml.v3" "github.com/alajmo/mani/core" ) func TestProject_GetValue(t *testing.T) { project := Project{ Name: "test-project", Path: "/path/to/project", RelPath: "relative/path", Desc: "Test description", URL: "https://example.com", Tags: []string{"frontend", "api"}, } tests := []struct { name string key string expected string }{ { name: "get project name", key: "Project", expected: "test-project", }, { name: "get project path", key: "Path", expected: "/path/to/project", }, { name: "get relative path", key: "RelPath", expected: "relative/path", }, { name: "get description", key: "Desc", expected: "Test description", }, { name: "get url", key: "Url", expected: "https://example.com", }, { name: "get tags", key: "Tag", expected: "frontend, api", }, { name: "get invalid key", key: "InvalidKey", expected: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := project.GetValue(tt.key, 0) if result != tt.expected { t.Errorf("expected %q, got %q", tt.expected, result) } }) } } func TestProject_GetProjectsByName(t *testing.T) { config := Config{ ProjectList: []Project{ {Name: "project1", Path: "/path/1"}, {Name: "project2", Path: "/path/2"}, {Name: "project3", Path: "/path/3"}, }, } tests := []struct { name string projectNames []string expectError bool expectedCount int }{ { name: "find existing projects", projectNames: []string{"project1", "project2"}, expectError: false, expectedCount: 2, }, { name: "find non-existing project", projectNames: []string{"project1", "nonexistent"}, expectError: true, expectedCount: 0, }, { name: "empty project names", projectNames: []string{}, expectError: false, expectedCount: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { projects, err := config.GetProjectsByName(tt.projectNames) if tt.expectError && err == nil { t.Error("expected error but got none") } if !tt.expectError && err != nil { t.Errorf("unexpected error: %v", err) } if len(projects) != tt.expectedCount { t.Errorf("expected %d projects, got %d", tt.expectedCount, len(projects)) } if err != nil && !tt.expectError { if _, ok := err.(*core.ProjectNotFound); !ok { t.Errorf("expected ProjectNotFound error, got %T", err) } } }) } } func TestProject_GetProjectsByTags(t *testing.T) { config := Config{ ProjectList: []Project{ {Name: "project1", Tags: []string{"frontend", "react"}}, {Name: "project2", Tags: []string{"backend", "api"}}, {Name: "project3", Tags: []string{"frontend", "vue"}}, }, } tests := []struct { name string tags []string expectError bool expectedNames []string }{ { name: "find projects with existing tag", tags: []string{"frontend"}, expectError: false, expectedNames: []string{"project1", "project3"}, }, { name: "find projects with multiple tags", tags: []string{"frontend", "react"}, expectError: false, expectedNames: []string{"project1"}, }, { name: "find projects with non-existing tag", tags: []string{"nonexistent"}, expectError: true, expectedNames: []string{}, }, { name: "empty tags", tags: []string{}, expectError: false, expectedNames: []string{"project1", "project2", "project3"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { projects, err := config.GetProjectsByTags(tt.tags) if tt.expectError && err == nil { t.Error("expected error but got none") } if !tt.expectError && err != nil { t.Errorf("unexpected error: %v", err) } gotNames := getProjectNames(projects) if !equalStringSlices(gotNames, tt.expectedNames) { t.Errorf("expected projects %v, got %v", tt.expectedNames, gotNames) } }) } } func TestProject_GetProjectsByPath(t *testing.T) { config := Config{ Dir: "/base", ProjectList: []Project{ {Name: "project1", Path: "/base/frontend/app1", RelPath: "frontend/app1"}, {Name: "project2", Path: "/base/backend/api", RelPath: "backend/api"}, {Name: "project3", Path: "/base/frontend/app2", RelPath: "frontend/app2"}, {Name: "project4", Path: "/base/frontend/nested/app3", RelPath: "frontend/nested/app3"}, }, } tests := []struct { name string paths []string expectError bool expectedNames []string }{ { name: "find projects in frontend path", paths: []string{"frontend"}, expectError: false, expectedNames: []string{"project1", "project3", "project4"}, }, { name: "find projects with specific path", paths: []string{"frontend/app1"}, expectError: false, expectedNames: []string{"project1"}, }, { name: "find projects with single-level glob (1)", paths: []string{"*/app*"}, expectError: false, expectedNames: []string{"project1", "project3"}, }, { name: "find projects with single-level glob (2)", paths: []string{"*/app?"}, expectError: false, expectedNames: []string{"project1", "project3"}, }, { name: "find projects with double-star glob (1)", paths: []string{"frontend/**/app*"}, expectError: false, expectedNames: []string{"project1", "project3", "project4"}, }, { name: "find projects with double-star glob (2)", paths: []string{"frontend/**/app?"}, expectError: false, expectedNames: []string{"project1", "project3", "project4"}, }, { name: "find projects with double-star glob (3)", paths: []string{"frontend/**/**/app?"}, expectError: false, expectedNames: []string{"project1", "project3", "project4"}, }, { name: "find projects with double-star glob (4)", paths: []string{"**/app?"}, expectError: false, expectedNames: []string{"project1", "project3", "project4"}, }, { name: "find projects with non-existing path", paths: []string{"nonexistent"}, expectError: true, expectedNames: []string{}, }, { name: "empty paths", paths: []string{}, expectError: false, expectedNames: []string{"project1", "project2", "project3", "project4"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { projects, err := config.GetProjectsByPath(tt.paths) if tt.expectError && err == nil { t.Error("expected error but got none") } if !tt.expectError && err != nil { t.Errorf("unexpected error: %v", err) } gotNames := getProjectNames(projects) if !equalStringSlices(gotNames, tt.expectedNames) { t.Errorf("expected projects %v, got %v", tt.expectedNames, gotNames) } }) } } func TestProject_TestAddToTree(t *testing.T) { tests := []struct { name string nodes []TNode expectedPaths []string }{ { name: "simple tree", nodes: []TNode{ {Name: "app1", Path: "frontend/app1"}, {Name: "app2", Path: "frontend/app2"}, {Name: "api", Path: "backend/api"}, }, expectedPaths: []string{"frontend", "backend"}, }, { name: "nested tree", nodes: []TNode{ {Name: "app1", Path: "frontend/web/app1"}, {Name: "app2", Path: "frontend/mobile/app2"}, }, expectedPaths: []string{"frontend"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var tree []TreeNode for _, node := range tt.nodes { tree = AddToTree(tree, node) } paths := getTreePaths(tree) if !equalStringSlices(paths, tt.expectedPaths) { t.Errorf("expected paths %v, got %v", tt.expectedPaths, paths) } }) } } func TestProject_GetIntersectProjects(t *testing.T) { config := Config{ ProjectList: []Project{ {Name: "project1", Tags: []string{"frontend"}}, {Name: "project2", Tags: []string{"backend"}}, {Name: "project3", Tags: []string{"frontend", "api"}}, }, } tests := []struct { name string inputs [][]Project expectedNames []string }{ { name: "intersect frontend and api projects", inputs: [][]Project{ {{Name: "project1"}, {Name: "project3"}}, // frontend projects {{Name: "project3"}}, // api projects }, expectedNames: []string{"project3"}, }, { name: "no intersection", inputs: [][]Project{ {{Name: "project1"}}, {{Name: "project2"}}, }, expectedNames: []string{}, }, { name: "empty input", inputs: [][]Project{}, expectedNames: []string{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := config.GetIntersectProjects(tt.inputs...) gotNames := getProjectNames(result) if !equalStringSlices(gotNames, tt.expectedNames) { t.Errorf("expected projects %v, got %v", tt.expectedNames, gotNames) } }) } } func TestParseWorktrees(t *testing.T) { tests := []struct { name string yaml string expected []Worktree expectError bool }{ { name: "worktree with path and branch", yaml: ` - path: feature-branch branch: feature/awesome `, expected: []Worktree{ {Path: "feature-branch", Branch: "feature/awesome"}, }, }, { name: "multiple worktrees", yaml: ` - path: feature-branch branch: feature/awesome - path: staging branch: staging `, expected: []Worktree{ {Path: "feature-branch", Branch: "feature/awesome"}, {Path: "staging", Branch: "staging"}, }, }, { name: "worktree without branch defaults to path basename", yaml: ` - path: hotfix `, expected: []Worktree{ {Path: "hotfix", Branch: "hotfix"}, }, }, { name: "worktree with nested path defaults branch to basename", yaml: ` - path: worktrees/feature `, expected: []Worktree{ {Path: "worktrees/feature", Branch: "feature"}, }, }, { name: "empty worktrees", yaml: ``, expected: []Worktree{}, }, { name: "worktree without path returns error", yaml: ` - branch: feat `, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var node yaml.Node if err := yaml.Unmarshal([]byte(tt.yaml), &node); err != nil { t.Fatalf("failed to parse yaml: %v", err) } // Handle empty YAML case if len(node.Content) == 0 { result, err := ParseWorktrees(yaml.Node{}) if err != nil { t.Errorf("unexpected error: %v", err) } if len(result) != 0 { t.Errorf("expected empty worktrees, got %v", result) } return } result, err := ParseWorktrees(*node.Content[0]) if tt.expectError { if err == nil { t.Error("expected error but got none") } return } if err != nil { t.Errorf("unexpected error: %v", err) return } if len(result) != len(tt.expected) { t.Errorf("expected %d worktrees, got %d", len(tt.expected), len(result)) return } for i, wt := range result { if wt.Path != tt.expected[i].Path { t.Errorf("worktree[%d].Path: expected %q, got %q", i, tt.expected[i].Path, wt.Path) } if wt.Branch != tt.expected[i].Branch { t.Errorf("worktree[%d].Branch: expected %q, got %q", i, tt.expected[i].Branch, wt.Branch) } } }) } } func TestProject_GetValue_Worktrees(t *testing.T) { tests := []struct { name string project Project expected string }{ { name: "project with worktrees", project: Project{ Name: "test-project", WorktreeList: []Worktree{ {Path: "feature", Branch: "feature/test"}, {Path: "staging", Branch: "staging"}, }, }, expected: "feature:feature/test, staging:staging", }, { name: "project without worktrees", project: Project{ Name: "test-project", WorktreeList: []Worktree{}, }, expected: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.project.GetValue("worktrees", 0) if result != tt.expected { t.Errorf("expected %q, got %q", tt.expected, result) } }) } } ================================================ FILE: core/dao/spec.go ================================================ package dao import ( "gopkg.in/yaml.v3" "github.com/alajmo/mani/core" ) type Spec struct { Name string `yaml:"name"` Output string `yaml:"output"` Parallel bool `yaml:"parallel"` IgnoreErrors bool `yaml:"ignore_errors"` IgnoreNonExisting bool `yaml:"ignore_non_existing"` OmitEmptyRows bool `yaml:"omit_empty_rows"` OmitEmptyColumns bool `yaml:"omit_empty_columns"` ClearOutput bool `yaml:"clear_output"` Forks uint32 `yaml:"forks"` context string contextLine int } func (s *Spec) GetContext() string { return s.context } func (s *Spec) GetContextLine() int { return s.contextLine } // Populates SpecList and creates a default spec if no default spec is set. func (c *Config) GetSpecList() ([]Spec, []ResourceErrors[Spec]) { var specs []Spec count := len(c.Specs.Content) specErrors := []ResourceErrors[Spec]{} foundErrors := false for i := 0; i < count; i += 2 { spec := &Spec{ Name: c.Specs.Content[i].Value, context: c.Path, contextLine: c.Specs.Content[i].Line, } err := c.Specs.Content[i+1].Decode(spec) if err != nil { foundErrors = true specError := ResourceErrors[Spec]{Resource: spec, Errors: core.StringsToErrors(err.(*yaml.TypeError).Errors)} specErrors = append(specErrors, specError) continue } switch spec.Output { case "", "table", "stream", "html", "markdown": default: foundErrors = true specError := ResourceErrors[Spec]{ Resource: spec, Errors: []error{&core.SpecOutputError{Name: spec.Name, Output: spec.Output}}, } specErrors = append(specErrors, specError) } if spec.Forks == 0 { spec.Forks = 4 } specs = append(specs, *spec) } if foundErrors { return specs, specErrors } return specs, nil } func (c Config) GetSpec(name string) (*Spec, error) { for _, spec := range c.SpecList { if name == spec.Name { return &spec, nil } } return nil, &core.SpecNotFound{Name: name} } func (c Config) GetSpecNames() []string { names := []string{} for _, spec := range c.SpecList { names = append(names, spec.Name) } return names } ================================================ FILE: core/dao/spec_test.go ================================================ package dao import ( "reflect" "testing" "github.com/alajmo/mani/core" ) func TestSpec_GetContext(t *testing.T) { spec := Spec{ Name: "test-spec", context: "/path/to/config", contextLine: 42, } if spec.GetContext() != "/path/to/config" { t.Errorf("expected context '/path/to/config', got %q", spec.GetContext()) } if spec.GetContextLine() != 42 { t.Errorf("expected context line 42, got %d", spec.GetContextLine()) } } func TestSpec_GetSpecList(t *testing.T) { tests := []struct { name string config Config expectedCount int expectError bool }{ { name: "empty spec list", config: Config{ SpecList: []Spec{}, }, expectedCount: 0, expectError: false, }, { name: "valid specs", config: Config{ SpecList: []Spec{ { Name: "spec1", Output: "table", Parallel: true, Forks: 4, }, { Name: "spec2", Output: "stream", Forks: 8, }, }, }, expectedCount: 2, expectError: false, }, { name: "spec with defaults", config: Config{ SpecList: []Spec{ { Name: "default-spec", Output: "table", Forks: 4, }, }, }, expectedCount: 1, expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { specs := tt.config.SpecList if len(specs) != tt.expectedCount { t.Errorf("expected %d specs, got %d", tt.expectedCount, len(specs)) } }) } } func TestSpec_GetSpec(t *testing.T) { config := Config{ SpecList: []Spec{ { Name: "spec1", Output: "table", Forks: 4, }, { Name: "spec2", Output: "stream", Forks: 8, }, }, } tests := []struct { name string specName string expectError bool expectedForks uint32 }{ { name: "existing spec", specName: "spec1", expectError: false, expectedForks: 4, }, { name: "non-existing spec", specName: "nonexistent", expectError: true, expectedForks: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { spec, err := config.GetSpec(tt.specName) if tt.expectError { if err == nil { t.Error("expected error but got none") } if _, ok := err.(*core.SpecNotFound); !ok { t.Errorf("expected SpecNotFound error, got %T", err) } return } if err != nil { t.Errorf("unexpected error: %v", err) return } if spec.Forks != tt.expectedForks { t.Errorf("expected forks %d, got %d", tt.expectedForks, spec.Forks) } }) } } func TestSpec_GetSpecNames(t *testing.T) { tests := []struct { name string config Config expectedNames []string }{ { name: "multiple specs", config: Config{ SpecList: []Spec{ {Name: "spec1"}, {Name: "spec2"}, {Name: "spec3"}, }, }, expectedNames: []string{"spec1", "spec2", "spec3"}, }, { name: "empty spec list", config: Config{ SpecList: []Spec{}, }, expectedNames: []string{}, }, { name: "single spec", config: Config{ SpecList: []Spec{ {Name: "solo-spec"}, }, }, expectedNames: []string{"solo-spec"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { names := tt.config.GetSpecNames() if !reflect.DeepEqual(names, tt.expectedNames) { t.Errorf("expected names %v, got %v", tt.expectedNames, names) } }) } } ================================================ FILE: core/dao/tag.go ================================================ package dao import ( "slices" "strings" ) type Tag struct { Name string Projects []string } func (t Tag) GetValue(key string, _ int) string { switch strings.ToLower(key) { case "tag": return t.Name case "project", "projects": return strings.Join(t.Projects, "\n") default: return "" } } func (c Config) GetTags() []string { tags := []string{} for _, project := range c.ProjectList { for _, tag := range project.Tags { if !slices.Contains(tags, tag) { tags = append(tags, tag) } } } return tags } func (c Config) GetTagAssocations(tags []string) ([]Tag, error) { t := []Tag{} for _, tag := range tags { projects, err := c.GetProjectsByTags([]string{tag}) if err != nil { return []Tag{}, err } var projectNames []string for _, p := range projects { projectNames = append(projectNames, p.Name) } t = append(t, Tag{Name: tag, Projects: projectNames}) } return t, nil } ================================================ FILE: core/dao/tag_expr.go ================================================ // Package dao for evaluating boolean tag expressions against project tags. package dao import ( "fmt" "slices" "strings" "unicode" ) type TokenType int const ( TokenTag TokenType = iota TokenAnd TokenOr TokenNot TokenLParent TokenRParen TokenEOF ) type Position struct { line int column int } type Token struct { Type TokenType Value string Position Position } type Lexer struct { input string pos int line int column int tokens []Token } func NewLexer(input string) *Lexer { return &Lexer{ input: input, pos: 0, line: 1, column: 1, tokens: make([]Token, 0), } } func (l *Lexer) Tokenize() error { if strings.TrimSpace(l.input) == "" { return fmt.Errorf("empty expression") } for l.pos < len(l.input) { char := l.current() switch { case char == ' ' || char == '\t': l.advance() case char == '\n': l.line++ l.column = 1 l.advance() case char == '(': l.addToken(TokenLParent, "(") l.advance() case char == ')': l.addToken(TokenRParen, ")") l.advance() case char == '!': l.addToken(TokenNot, "!") l.advance() case l.matchOperator("&&"): l.addToken(TokenAnd, "&&") l.advance() l.advance() case l.matchOperator("||"): l.addToken(TokenOr, "||") l.advance() l.advance() case isValidTagStart(char): l.readTag() default: return fmt.Errorf("unexpected character: %c at line %d, column %d", char, l.line, l.column) } } l.addToken(TokenEOF, "") return nil } func (l *Lexer) addToken(tokenType TokenType, value string) { l.tokens = append(l.tokens, Token{ Type: tokenType, Value: value, Position: Position{line: l.line, column: l.column}, }) } func (l *Lexer) advance() { l.pos++ l.column++ } func (l *Lexer) current() rune { if l.pos >= len(l.input) { return 0 } return rune(l.input[l.pos]) } func (l *Lexer) matchOperator(op string) bool { if l.pos+len(op) > len(l.input) { return false } return l.input[l.pos:l.pos+len(op)] == op } func (l *Lexer) readTag() { startPos := l.pos startColumn := l.column // First character must be a letter if !isValidTagStart(l.current()) { return } l.advance() // Subsequent characters can be letters, numbers, hyphens, or underscores for l.pos < len(l.input) && isValidTagPart(l.current()) { l.advance() } value := l.input[startPos:l.pos] l.tokens = append(l.tokens, Token{ Type: TokenTag, Value: value, Position: Position{line: l.line, column: startColumn}, }) } func isValidTagStart(r rune) bool { return !isReservedChar(r) && !unicode.IsSpace(r) } func isValidTagPart(r rune) bool { return !isReservedChar(r) && !unicode.IsSpace(r) } func isReservedChar(r rune) bool { return r == '(' || r == ')' || r == '!' || r == '&' || r == '|' } type Parser struct { tokens []Token pos int project *Project } func NewParser(tokens []Token, project *Project) *Parser { return &Parser{ tokens: tokens, pos: 0, project: project, } } func (p *Parser) Parse() (bool, error) { if len(p.tokens) <= 1 { // Only EOF token return false, fmt.Errorf("empty expression") } result, err := p.parseExpression() if err != nil { return false, err } // Check if we consumed all tokens if p.current().Type != TokenEOF { pos := p.current().Position return false, fmt.Errorf("unexpected token at line %d, column %d", pos.line, pos.column) } return result, nil } func (p *Parser) parseExpression() (bool, error) { left, err := p.parseTerm() if err != nil { return false, err } for p.current().Type == TokenOr { op := p.current() p.pos++ // Check for missing right operand if p.current().Type == TokenEOF { return false, fmt.Errorf("missing right operand for OR operator at line %d, column %d", op.Position.line, op.Position.column) } right, err := p.parseTerm() if err != nil { return false, err } left = left || right } return left, nil } func (p *Parser) parseTerm() (bool, error) { left, err := p.parseFactor() if err != nil { return false, err } for p.current().Type == TokenAnd { op := p.current() p.pos++ // Check for missing right operand if p.current().Type == TokenEOF { return false, fmt.Errorf("missing right operand for AND operator at line %d, column %d", op.Position.line, op.Position.column) } right, err := p.parseFactor() if err != nil { return false, err } left = left && right } return left, nil } func (p *Parser) parseFactor() (bool, error) { token := p.current() switch token.Type { case TokenNot: p.pos++ if p.current().Type == TokenEOF { return false, fmt.Errorf("missing operand after NOT at line %d, column %d", token.Position.line, token.Position.column) } val, err := p.parseFactor() if err != nil { return false, err } return !val, nil case TokenLParent: p.pos++ // Check for empty parentheses if p.current().Type == TokenRParen { return false, fmt.Errorf("empty parentheses at line %d, column %d", token.Position.line, token.Position.column) } val, err := p.parseExpression() if err != nil { return false, err } if p.current().Type != TokenRParen { return false, fmt.Errorf("missing closing parenthesis for opening parenthesis at line %d, column %d", token.Position.line, token.Position.column) } p.pos++ return val, nil case TokenTag: p.pos++ return slices.Contains(p.project.Tags, token.Value), nil default: return false, fmt.Errorf("unexpected token at line %d, column %d: %v", token.Position.line, token.Position.column, token.Value) } } func (p *Parser) current() Token { if p.pos >= len(p.tokens) { return Token{Type: TokenEOF} } return p.tokens[p.pos] } // evaluateExpression checks if a boolean tag expression evaluates to true for a given project. // The function supports boolean operations on project tags with full operator precedence. // // Operators (in precedence order): // 1. () - Parentheses for grouping // 2. ! - NOT operator (logical negation) // 3. && - AND operator (logical conjunction) // 4. || - OR operator (logical disjunction) // // Tag Expression Example: // // Expression: (main && (dev || prod)) && !test // // Requirements: // 1. Must have "main" tag - Mandatory // 2. Must have "dev" OR "prod" tag - At least one required // 3. Must NOT have "test" tag - Excluded if present // // Matches tags: // // ["main", "dev"] // ["main", "prod"] // ["main", "dev", "prod"] // // Does NOT match tags: // // ["main"] - missing dev/prod // ["main", "dev", "test"] - has test tag // ["dev", "prod"] - missing main func evaluateExpression(project *Project, expression string) (bool, error) { lexer := NewLexer(expression) err := lexer.Tokenize() if err != nil { return false, fmt.Errorf("lexer error: %v", err) } parser := NewParser(lexer.tokens, project) return parser.Parse() } func validateExpression(expression string) error { lexer := NewLexer(expression) err := lexer.Tokenize() if err != nil { return fmt.Errorf("%v", err) } project := &Project{Tags: []string{}} parser := NewParser(lexer.tokens, project) _, err = parser.Parse() if err != nil { return fmt.Errorf("%v", err) } return nil } ================================================ FILE: core/dao/tag_expr_test.go ================================================ package dao import ( "strings" "testing" ) func TestTagExpression(t *testing.T) { projects := []Project{ { Name: "Project A", Tags: []string{"active", "git", "frontend"}, }, { Name: "Project B", Tags: []string{"active", "sake", "backend"}, }, } // Test cases for valid expressions validTests := []struct { name string expr string project string expected bool }{ {"simple AND", "active && git", "Project A", true}, {"simple AND false", "active && git", "Project B", false}, {"simple OR", "git || sake", "Project A", true}, {"simple OR", "git || sake", "Project B", true}, {"nested AND-OR", "((active && git) || (sake && backend))", "Project A", true}, {"nested AND-OR", "((active && git) || (sake && backend))", "Project B", true}, {"parentheses precedence", "(active && (git || sake))", "Project A", true}, {"parentheses precedence", "(active && (git || sake))", "Project B", true}, {"complex expression", "((active && git) || (active && sake)) && (frontend || backend)", "Project A", true}, {"complex expression", "((active && git) || (active && sake)) && (frontend || backend)", "Project B", true}, {"NOT operator", "!(active && (git || sake))", "Project A", false}, {"NOT operator", "!(active && (git || sake))", "Project B", false}, {"triple nested", "(((active && git) || sake) && backend)", "Project A", false}, {"triple nested", "(((active && git) || sake) && backend)", "Project B", true}, } t.Run("valid expressions", func(t *testing.T) { for _, tt := range validTests { t.Run(tt.name, func(t *testing.T) { var proj Project for _, p := range projects { if p.Name == tt.project { proj = p break } } result, err := evaluateExpression(&proj, tt.expr) if err != nil { t.Errorf("unexpected error: %v", err) } if result != tt.expected { t.Errorf("expression %q on project %q: got %v, want %v", tt.expr, tt.project, result, tt.expected) } }) } }) // Test cases for invalid expressions invalidTests := []struct { name string expr string expectedErr string }{ {"empty expression", "", "empty expression"}, {"operator without operands", "&&", "unexpected token"}, {"missing right operand", "tag &&", "missing right operand"}, {"missing left operand", "&& tag", "unexpected token"}, {"empty parentheses", "()", "empty parentheses"}, {"unmatched parenthesis", "((tag)", "missing closing parenthesis"}, {"missing operator", "tag tag", "unexpected token"}, {"double operator", "tag && && tag", "unexpected token"}, {"NOT without operand", "!", "missing operand after NOT"}, } t.Run("invalid expressions", func(t *testing.T) { for _, tt := range invalidTests { t.Run(tt.name, func(t *testing.T) { _, err := evaluateExpression(&projects[0], tt.expr) if err == nil { t.Errorf("expected error containing %q, got nil", tt.expectedErr) return } if !strings.Contains(err.Error(), tt.expectedErr) { t.Errorf("expected error containing %q, got %q", tt.expectedErr, err.Error()) } }) } }) } ================================================ FILE: core/dao/target.go ================================================ package dao import ( "gopkg.in/yaml.v3" "github.com/alajmo/mani/core" ) type Target struct { Name string `yaml:"name"` All bool `yaml:"all"` Projects []string `yaml:"projects"` Paths []string `yaml:"paths"` Tags []string `yaml:"tags"` TagsExpr string `yaml:"tags_expr"` Cwd bool `yaml:"cwd"` context string contextLine int } func (t *Target) GetContext() string { return t.context } func (t *Target) GetContextLine() int { return t.contextLine } // Populates TargetList and creates a default target if no default target is set. func (c *Config) GetTargetList() ([]Target, []ResourceErrors[Target]) { var targets []Target count := len(c.Targets.Content) targetErrors := []ResourceErrors[Target]{} foundErrors := false for i := 0; i < count; i += 2 { target := &Target{ Name: c.Targets.Content[i].Value, context: c.Path, contextLine: c.Targets.Content[i].Line, } err := c.Targets.Content[i+1].Decode(target) if err != nil { foundErrors = true targetError := ResourceErrors[Target]{Resource: target, Errors: core.StringsToErrors(err.(*yaml.TypeError).Errors)} targetErrors = append(targetErrors, targetError) continue } if target.TagsExpr != "" { valid := validateExpression(target.TagsExpr) if valid != nil { foundErrors = true targetError := ResourceErrors[Target]{ Resource: target, Errors: []error{&core.TargetTagsExprError{Name: target.Name, Err: valid}}, } targetErrors = append(targetErrors, targetError) } } targets = append(targets, *target) } if foundErrors { return targets, targetErrors } return targets, nil } func (c Config) GetTarget(name string) (*Target, error) { for _, target := range c.TargetList { if name == target.Name { return &target, nil } } return nil, &core.TargetNotFound{Name: name} } func (c Config) GetTargetNames() []string { names := []string{} for _, target := range c.TargetList { names = append(names, target.Name) } return names } ================================================ FILE: core/dao/target_test.go ================================================ package dao import ( "reflect" "testing" "github.com/alajmo/mani/core" ) func TestTarget_GetContext(t *testing.T) { target := Target{ Name: "test-target", context: "/path/to/config", contextLine: 42, } if target.GetContext() != "/path/to/config" { t.Errorf("expected context '/path/to/config', got %q", target.GetContext()) } if target.GetContextLine() != 42 { t.Errorf("expected context line 42, got %d", target.GetContextLine()) } } func TestTarget_GetTargetList(t *testing.T) { tests := []struct { name string config Config expectedCount int expectError bool }{ { name: "empty target list", config: Config{ TargetList: []Target{}, }, expectedCount: 0, expectError: false, }, { name: "multiple valid targets", config: Config{ TargetList: []Target{ { Name: "target1", Projects: []string{"proj1", "proj2"}, Tags: []string{"frontend"}, }, { Name: "target2", Projects: []string{"proj3"}, Tags: []string{"backend"}, }, }, }, expectedCount: 2, expectError: false, }, { name: "target with all flag", config: Config{ TargetList: []Target{ { Name: "all-target", All: true, }, }, }, expectedCount: 1, expectError: false, }, { name: "target with paths", config: Config{ TargetList: []Target{ { Name: "path-target", Paths: []string{"path1", "path2"}, }, }, }, expectedCount: 1, expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { targets := tt.config.TargetList if len(targets) != tt.expectedCount { t.Errorf("expected %d targets, got %d", tt.expectedCount, len(targets)) } }) } } func TestTarget_GetTarget(t *testing.T) { config := Config{ TargetList: []Target{ { Name: "frontend", Projects: []string{"web", "mobile"}, Tags: []string{"frontend"}, }, { Name: "backend", Projects: []string{"api", "worker"}, Tags: []string{"backend"}, }, }, } tests := []struct { name string targetName string expectError bool expectedTags []string }{ { name: "existing target", targetName: "frontend", expectError: false, expectedTags: []string{"frontend"}, }, { name: "non-existing target", targetName: "nonexistent", expectError: true, expectedTags: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { target, err := config.GetTarget(tt.targetName) if tt.expectError { if err == nil { t.Error("expected error but got none") } if _, ok := err.(*core.TargetNotFound); !ok { t.Errorf("expected TargetNotFound error, got %T", err) } return } if err != nil { t.Errorf("unexpected error: %v", err) return } if !reflect.DeepEqual(target.Tags, tt.expectedTags) { t.Errorf("expected tags %v, got %v", tt.expectedTags, target.Tags) } }) } } func TestTarget_GetTargetNames(t *testing.T) { tests := []struct { name string config Config expectedNames []string }{ { name: "multiple targets", config: Config{ TargetList: []Target{ {Name: "target1"}, {Name: "target2"}, {Name: "target3"}, }, }, expectedNames: []string{"target1", "target2", "target3"}, }, { name: "empty target list", config: Config{ TargetList: []Target{}, }, expectedNames: []string{}, }, { name: "single target", config: Config{ TargetList: []Target{ {Name: "solo-target"}, }, }, expectedNames: []string{"solo-target"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { names := tt.config.GetTargetNames() if !reflect.DeepEqual(names, tt.expectedNames) { t.Errorf("expected names %v, got %v", tt.expectedNames, names) } }) } } ================================================ FILE: core/dao/task.go ================================================ package dao import ( "errors" "fmt" "io" "strings" "time" "github.com/jinzhu/copier" "github.com/theckman/yacspin" "gopkg.in/yaml.v3" core "github.com/alajmo/mani/core" ) var ( buildMode = "dev" ) type Command struct { Name string `yaml:"name"` Desc string `yaml:"desc"` Shell string `yaml:"shell"` // should be in the format: , for instance "sh -c", "node -e" Cmd string `yaml:"cmd"` // "echo hello world", it should not include the program flag (-c,-e, .etc) Task string `yaml:"task"` TaskRef string `yaml:"-"` // Keep a reference to the task TTY bool `yaml:"tty"` Env yaml.Node `yaml:"env"` EnvList []string `yaml:"-"` // Internal ShellProgram string `yaml:"-"` // should be in the format: , example: "sh", "node" CmdArg []string `yaml:"-"` // is in the format ["-c echo hello world"] or ["-c", "echo hello world"], it includes the shell flag } type Task struct { SpecData Spec TargetData Target ThemeData Theme Name string `yaml:"name"` Desc string `yaml:"desc"` Shell string `yaml:"shell"` Cmd string `yaml:"cmd"` Commands []Command `yaml:"commands"` EnvList []string `yaml:"-"` TTY bool `yaml:"tty"` Env yaml.Node `yaml:"env"` Spec yaml.Node `yaml:"spec"` Target yaml.Node `yaml:"target"` Theme yaml.Node `yaml:"theme"` // Internal ShellProgram string `yaml:"-"` // should be in the format: , example: "sh", "node" CmdArg []string `yaml:"-"` // is in the format ["-c echo hello world"] or ["-c", "echo hello world"], it includes the shell flag context string contextLine int } func (t *Task) GetContext() string { return t.context } func (t *Task) GetContextLine() int { return t.contextLine } // ParseTask parses tasks and builds the correct "AST". Depending on if the data is specified inline, // or if it is a reference to resource, it will handle them differently. func (t *Task) ParseTask(config Config, taskErrors *ResourceErrors[Task]) { if t.Shell == "" { t.Shell = config.Shell } else { t.Shell = core.FormatShell(t.Shell) } program, cmdArgs := core.FormatShellString(t.Shell, t.Cmd) t.ShellProgram = program t.CmdArg = cmdArgs for j, cmd := range t.Commands { // Task reference if cmd.Task != "" { cmdRef, err := config.GetCommand(cmd.Task) if err != nil { taskErrors.Errors = append(taskErrors.Errors, err) continue } t.Commands[j] = *cmdRef t.Commands[j].TaskRef = cmd.Task } if t.Commands[j].Shell == "" { t.Commands[j].Shell = DEFAULT_SHELL } program, cmdArgs := core.FormatShellString(t.Commands[j].Shell, t.Commands[j].Cmd) t.Commands[j].ShellProgram = program t.Commands[j].CmdArg = cmdArgs } if len(t.Theme.Content) > 0 { // Theme value theme := &Theme{} err := t.Theme.Decode(theme) if err != nil { taskErrors.Errors = append(taskErrors.Errors, err) } else { t.ThemeData = *theme } } else if t.Theme.Value != "" { // Theme reference theme, err := config.GetTheme(t.Theme.Value) if err != nil { taskErrors.Errors = append(taskErrors.Errors, err) } else { t.ThemeData = *theme } } else { // Default theme theme, err := config.GetTheme(DEFAULT_THEME.Name) if err != nil { taskErrors.Errors = append(taskErrors.Errors, err) } else { t.ThemeData = *theme } } if len(t.Spec.Content) > 0 { // Spec value spec := &Spec{} err := t.Spec.Decode(spec) if err != nil { taskErrors.Errors = append(taskErrors.Errors, err) } else { t.SpecData = *spec } } else if t.Spec.Value != "" { // Spec reference spec, err := config.GetSpec(t.Spec.Value) if err != nil { taskErrors.Errors = append(taskErrors.Errors, err) } else { t.SpecData = *spec } } else { // Default spec spec, err := config.GetSpec(DEFAULT_SPEC.Name) if err != nil { taskErrors.Errors = append(taskErrors.Errors, err) } else { t.SpecData = *spec } } if len(t.Target.Content) > 0 { // Target value target := &Target{} err := t.Target.Decode(target) if err != nil { taskErrors.Errors = append(taskErrors.Errors, err) } else { t.TargetData = *target } } else if t.Target.Value != "" { // Target reference target, err := config.GetTarget(t.Target.Value) if err != nil { taskErrors.Errors = append(taskErrors.Errors, err) } else { t.TargetData = *target } } else { // Default target target, err := config.GetTarget(DEFAULT_TARGET.Name) if err != nil { taskErrors.Errors = append(taskErrors.Errors, err) } else { t.TargetData = *target } } } func TaskSpinner() (yacspin.Spinner, error) { var cfg yacspin.Config // NOTE: Don't print the spinner in tests since it causes // golden files to produce different results. if buildMode == "TEST" { cfg = yacspin.Config{ Frequency: 100 * time.Millisecond, CharSet: yacspin.CharSets[9], SuffixAutoColon: false, Writer: io.Discard, } } else { cfg = yacspin.Config{ Frequency: 100 * time.Millisecond, CharSet: yacspin.CharSets[9], SuffixAutoColon: false, ShowCursor: true, } } spinner, err := yacspin.New(cfg) return *spinner, err } func (t Task) GetValue(key string, _ int) string { switch strings.ToLower(key) { case "name", "task": return t.Name case "desc", "description": return t.Desc case "command": return t.Cmd case "spec": return t.SpecData.Name case "target": return t.TargetData.Name default: return "" } } func (c *Config) GetTaskList() ([]Task, []ResourceErrors[Task]) { var tasks []Task count := len(c.Tasks.Content) taskErrors := []ResourceErrors[Task]{} foundErrors := false for i := 0; i < count; i += 2 { task := &Task{ Name: c.Tasks.Content[i].Value, context: c.Path, contextLine: c.Tasks.Content[i].Line, } // Shorthand definition: example_task: echo 123 if c.Tasks.Content[i+1].Kind == 8 { task.Cmd = c.Tasks.Content[i+1].Value } else { // Full definition err := c.Tasks.Content[i+1].Decode(task) if err != nil { foundErrors = true taskError := ResourceErrors[Task]{Resource: task, Errors: core.StringsToErrors(err.(*yaml.TypeError).Errors)} taskErrors = append(taskErrors, taskError) continue } } tasks = append(tasks, *task) } if foundErrors { return tasks, taskErrors } return tasks, nil } func ParseTaskEnv( env yaml.Node, userEnv []string, parentEnv []string, configEnv []string, ) ([]string, error) { cmdEnv, err := EvaluateEnv(ParseNodeEnv(env)) if err != nil { return []string{}, err } pEnv, err := EvaluateEnv(parentEnv) if err != nil { return []string{}, err } envList := MergeEnvs(userEnv, cmdEnv, pEnv, configEnv) return envList, nil } func ParseTasksEnv(tasks []Task) { for i := range tasks { envs, err := ParseTaskEnv(tasks[i].Env, []string{}, []string{}, []string{}) core.CheckIfError(err) tasks[i].EnvList = envs for j := range tasks[i].Commands { envs, err = ParseTaskEnv(tasks[i].Commands[j].Env, []string{}, []string{}, []string{}) core.CheckIfError(err) tasks[i].Commands[j].EnvList = envs } } } // GetTaskProjects retrieves a filtered list of projects for a given task, applying // runtime flag overrides and target configurations. // // Behavior depends on the provided runtime flags (flags, setFlags) and task target: // - If runtime flags are set (Projects, Paths, Tags, etc.), they take precedence // and reset the task's target configuration. // - If a target is explicitly specified (flags.Target), it loads and applies that // target's configuration before applying runtime flag overrides. // - If no runtime flags or target are provided, the task's default target data is used. // // Filtering priority (highest to lowest): // 1. Runtime flags (e.g., --projects, --tags, --cwd) // 2. Explicit target configuration (--target) // 3. Task's default target data (if no overrides exist) // // Returns: // - Filtered []Project based on the resolved configuration. // - Non-nil error if target resolution or project filtering fails. func (c Config) GetTaskProjects( task *Task, flags *core.RunFlags, setFlags *core.SetRunFlags, ) ([]Project, error) { var err error var projects []Project // Reset target if any runtime flags are used if len(flags.Projects) > 0 || len(flags.Paths) > 0 || len(flags.Tags) > 0 || flags.TagsExpr != "" || flags.Target != "" || setFlags.Cwd || setFlags.All { task.TargetData = Target{} } if flags.Target != "" { target, err := c.GetTarget(flags.Target) if err != nil { return []Project{}, err } task.TargetData = *target } if len(flags.Projects) > 0 { task.TargetData.Projects = flags.Projects } if len(flags.Paths) > 0 { task.TargetData.Paths = flags.Paths } if len(flags.Tags) > 0 { task.TargetData.Tags = flags.Tags } if flags.TagsExpr != "" { task.TargetData.TagsExpr = flags.TagsExpr } if setFlags.Cwd { task.TargetData.Cwd = flags.Cwd } if setFlags.All { task.TargetData.All = flags.All } projects, err = c.FilterProjects( task.TargetData.Cwd, task.TargetData.All, task.TargetData.Projects, task.TargetData.Paths, task.TargetData.Tags, task.TargetData.TagsExpr, ) if err != nil { return []Project{}, err } return projects, nil } func (c Config) GetTasksByNames(names []string) ([]Task, error) { if len(names) == 0 { return c.TaskList, nil } foundTasks := make(map[string]bool) for _, t := range names { foundTasks[t] = false } var filteredTasks []Task for _, name := range names { if foundTasks[name] { continue } for _, task := range c.TaskList { if name == task.Name { foundTasks[task.Name] = true filteredTasks = append(filteredTasks, task) } } } nonExistingTasks := []string{} for k, v := range foundTasks { if !v { nonExistingTasks = append(nonExistingTasks, k) } } if len(nonExistingTasks) > 0 { return []Task{}, &core.TaskNotFound{Name: nonExistingTasks} } return filteredTasks, nil } func (c Config) GetTaskNames() []string { taskNames := []string{} for _, task := range c.TaskList { taskNames = append(taskNames, task.Name) } return taskNames } func (c Config) GetTaskNameAndDesc() []string { taskNames := []string{} for _, task := range c.TaskList { taskNames = append(taskNames, fmt.Sprintf("%s\t%s", task.Name, task.Desc)) } return taskNames } func (c Config) GetTask(name string) (*Task, error) { for _, cmd := range c.TaskList { if name == cmd.Name { return &cmd, nil } } return nil, &core.TaskNotFound{Name: []string{name}} } func (c Config) GetCommand(taskName string) (*Command, error) { for _, cmd := range c.TaskList { if taskName == cmd.Name { cmdRef := &Command{ Name: cmd.Name, Desc: cmd.Desc, EnvList: cmd.EnvList, Shell: cmd.Shell, Cmd: cmd.Cmd, } return cmdRef, nil } } return nil, &core.TaskNotFound{Name: []string{taskName}} } func (t Task) ConvertTaskToCommand() Command { cmd := Command{ Name: t.Name, Desc: t.Desc, EnvList: t.EnvList, Shell: t.Shell, Cmd: t.Cmd, CmdArg: t.CmdArg, ShellProgram: t.ShellProgram, } return cmd } func ParseCmd( cmd string, runFlags *core.RunFlags, setFlags *core.SetRunFlags, config *Config, ) ([]Task, []Project, error) { task := Task{Name: "output", Cmd: cmd, TTY: runFlags.TTY} taskErrors := make([]ResourceErrors[Task], 1) task.ParseTask(*config, &taskErrors[0]) var configErr = "" for _, taskError := range taskErrors { if len(taskError.Errors) > 0 { configErr = fmt.Sprintf("%s%s", configErr, FormatErrors(taskError.Resource, taskError.Errors)) } } if configErr != "" { core.CheckIfError(errors.New(configErr)) } projects, err := config.GetTaskProjects(&task, runFlags, setFlags) if err != nil { return nil, nil, err } core.CheckIfError(err) var tasks []Task for range projects { t := Task{} err := copier.Copy(&t, &task) core.CheckIfError(err) tasks = append(tasks, t) } if len(projects) == 0 { return nil, nil, &core.NoTargets{} } return tasks, projects, err } func ParseSingleTask( taskName string, runFlags *core.RunFlags, setFlags *core.SetRunFlags, config *Config, ) ([]Task, []Project, error) { task, err := config.GetTask(taskName) core.CheckIfError(err) projects, err := config.GetTaskProjects(task, runFlags, setFlags) core.CheckIfError(err) var tasks []Task for range projects { t := Task{} err := copier.Copy(&t, &task) core.CheckIfError(err) tasks = append(tasks, t) } if len(projects) == 0 { return nil, nil, &core.NoTargets{} } return tasks, projects, err } func ParseManyTasks( taskNames []string, runFlags *core.RunFlags, setFlags *core.SetRunFlags, config *Config, ) ([]Task, []Project, error) { parentTask := Task{Name: "Tasks", Cmd: "", Commands: []Command{}} taskErrors := make([]ResourceErrors[Task], 1) parentTask.ParseTask(*config, &taskErrors[0]) for _, taskName := range taskNames { task, err := config.GetTask(taskName) core.CheckIfError(err) if task.Cmd != "" { cmd := task.ConvertTaskToCommand() parentTask.Commands = append(parentTask.Commands, cmd) } else if len(task.Commands) > 0 { parentTask.Commands = append(parentTask.Commands, task.Commands...) } } projects, err := config.GetTaskProjects(&parentTask, runFlags, setFlags) var tasks []Task for range projects { t := Task{} err := copier.Copy(&t, &parentTask) core.CheckIfError(err) tasks = append(tasks, t) } if len(projects) == 0 { return nil, nil, &core.NoTargets{} } return tasks, projects, err } ================================================ FILE: core/dao/task_test.go ================================================ package dao import ( "reflect" "sort" "testing" "github.com/alajmo/mani/core" ) func TestTask_ParseTask(t *testing.T) { config := Config{ Shell: "sh -c", SpecList: []Spec{ DEFAULT_SPEC, }, TargetList: []Target{ DEFAULT_TARGET, }, ThemeList: []Theme{ DEFAULT_THEME, }, } tests := []struct { name string task Task expectError bool expectedShell string }{ { name: "basic task parsing", task: Task{ Name: "test-task", Cmd: "echo hello", SpecData: DEFAULT_SPEC, TargetData: DEFAULT_TARGET, ThemeData: DEFAULT_THEME, }, expectError: false, expectedShell: "sh -c", }, { name: "custom shell", task: Task{ Name: "node-task", Shell: "node -e", Cmd: "console.log('hello')", SpecData: DEFAULT_SPEC, TargetData: DEFAULT_TARGET, ThemeData: DEFAULT_THEME, }, expectError: false, expectedShell: "node -e", }, { name: "with commands", task: Task{ Name: "multi-cmd", Commands: []Command{ { Name: "cmd1", Cmd: "echo first", }, { Name: "cmd2", Cmd: "echo second", }, }, SpecData: DEFAULT_SPEC, TargetData: DEFAULT_TARGET, ThemeData: DEFAULT_THEME, }, expectError: false, expectedShell: "sh -c", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { taskErrors := &ResourceErrors[Task]{} tt.task.ParseTask(config, taskErrors) if tt.expectError && len(taskErrors.Errors) == 0 { t.Error("expected errors but got none") } if !tt.expectError && len(taskErrors.Errors) > 0 { t.Errorf("unexpected errors: %v", taskErrors.Errors) } if tt.task.Shell != tt.expectedShell { t.Errorf("expected shell %q, got %q", tt.expectedShell, tt.task.Shell) } }) } } func TestTask_GetTaskProjects(t *testing.T) { config := Config{ Shell: DEFAULT_SHELL, ProjectList: []Project{ {Name: "proj1", Tags: []string{"frontend"}}, {Name: "proj2", Tags: []string{"backend"}}, {Name: "proj3", Tags: []string{"frontend", "api"}}, }, SpecList: []Spec{ DEFAULT_SPEC, }, TargetList: []Target{ DEFAULT_TARGET, }, ThemeList: []Theme{ DEFAULT_THEME, }, } tests := []struct { name string task *Task flags *core.RunFlags setFlags *core.SetRunFlags expectedCount int expectError bool }{ { name: "filter by tags", task: &Task{ Name: "test-task", Shell: DEFAULT_SHELL, TargetData: Target{ Name: "default", Tags: []string{"frontend"}, }, SpecData: DEFAULT_SPEC, ThemeData: DEFAULT_THEME, }, flags: &core.RunFlags{}, setFlags: &core.SetRunFlags{}, expectedCount: 2, expectError: false, }, { name: "filter by projects", task: &Task{ Name: "test-task", TargetData: Target{ Name: DEFAULT_TARGET.Name, Projects: []string{"proj1", "proj2"}, }, SpecData: DEFAULT_SPEC, ThemeData: DEFAULT_THEME, }, flags: &core.RunFlags{}, setFlags: &core.SetRunFlags{}, expectedCount: 2, expectError: false, }, { name: "override with flag projects", task: &Task{ Name: "test-task", TargetData: Target{ Name: DEFAULT_TARGET.Name, Projects: []string{"proj1"}, }, SpecData: DEFAULT_SPEC, ThemeData: DEFAULT_THEME, }, flags: &core.RunFlags{ Projects: []string{"proj2", "proj3"}, }, setFlags: &core.SetRunFlags{}, expectedCount: 2, expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { projects, err := config.GetTaskProjects(tt.task, tt.flags, tt.setFlags) if tt.expectError && err == nil { t.Error("expected error but got none") } if !tt.expectError && err != nil { t.Errorf("unexpected error: %v", err) } if len(projects) != tt.expectedCount { t.Errorf("expected %d projects, got %d", tt.expectedCount, len(projects)) } }) } } func TestTask_CmdParse(t *testing.T) { config := &Config{ Shell: DEFAULT_SHELL, ProjectList: []Project{ {Name: "test-project", Path: "/test/path"}, }, SpecList: []Spec{ DEFAULT_SPEC, }, TargetList: []Target{ DEFAULT_TARGET, }, ThemeList: []Theme{ DEFAULT_THEME, }, } tests := []struct { name string cmd string runFlags *core.RunFlags setFlags *core.SetRunFlags expectTasks int expectProjects int expectError bool }{ { name: "basic command", cmd: "echo hello", runFlags: &core.RunFlags{ Target: "default", Projects: []string{"test-project"}, }, setFlags: &core.SetRunFlags{}, expectTasks: 1, expectProjects: 1, expectError: false, }, { name: "command with no matching projects", cmd: "echo hello", runFlags: &core.RunFlags{ Projects: []string{"non-existent"}, Target: "default", }, setFlags: &core.SetRunFlags{}, expectTasks: 0, expectProjects: 0, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tasks, projects, err := ParseCmd(tt.cmd, tt.runFlags, tt.setFlags, config) if tt.expectError && err == nil { t.Error("expected error but got none") } if !tt.expectError && err != nil { t.Errorf("unexpected error: %v", err) } if len(tasks) != tt.expectTasks { t.Errorf("expected %d tasks, got %d", tt.expectTasks, len(tasks)) } if len(projects) != tt.expectProjects { t.Errorf("expected %d projects, got %d", tt.expectProjects, len(projects)) } }) } } func TestConfig_FilterProjects(t *testing.T) { // Setup test configuration with sample projects config := Config{ ProjectList: []Project{ {Name: "root", Path: "/path", RelPath: ".", Tags: []string{}}, {Name: "frontend", Path: "/path/frontend", RelPath: "frontend", Tags: []string{"web", "ui"}}, {Name: "backend", Path: "/path/backend", RelPath: "backend", Tags: []string{"api", "db"}}, {Name: "mobile", Path: "/path/mobile", RelPath: "mobile", Tags: []string{"ui", "app"}}, {Name: "docs", Path: "/path/docs", RelPath: "docs", Tags: []string{"docs"}}, {Name: "shared", Path: "/path/shared", RelPath: "shared", Tags: []string{"lib", "shared"}}, }, } tests := []struct { name string cwdFlag bool allProjectsFlag bool projectsFlag []string projectPathsFlag []string tagsFlag []string tagsExprFlag string expectedCount int expectedNames []string expectError bool }{ { name: "single project", allProjectsFlag: true, projectsFlag: []string{"frontend"}, tagsFlag: []string{"ui"}, expectedCount: 1, expectedNames: []string{"frontend"}, expectError: false, }, { name: "filter by project names", projectsFlag: []string{"frontend", "backend"}, expectedCount: 2, expectedNames: []string{"frontend", "backend"}, expectError: false, }, { name: "partial path matching", projectPathsFlag: []string{"front"}, // Should match 'frontend' expectedCount: 1, expectedNames: []string{"frontend"}, expectError: false, }, { name: "filter by single tag", tagsFlag: []string{"ui"}, expectedCount: 2, expectedNames: []string{"frontend", "mobile"}, expectError: false, }, { name: "filter by multiple tags - intersection", tagsFlag: []string{"ui", "web"}, expectedCount: 1, expectedNames: []string{"frontend"}, expectError: false, }, { name: "filter by project paths", projectPathsFlag: []string{"frontend"}, expectedCount: 1, expectedNames: []string{"frontend"}, expectError: false, }, { name: "filter by tags expression", tagsExprFlag: "ui && !web", expectedCount: 1, expectedNames: []string{"mobile"}, expectError: false, }, { name: "multiple criteria - intersection", projectsFlag: []string{"frontend", "mobile", "backend"}, tagsFlag: []string{"ui"}, expectedCount: 2, expectedNames: []string{"frontend", "mobile"}, expectError: false, }, { name: "non-existent project name", projectsFlag: []string{"nonexistent"}, expectedCount: 0, expectError: true, }, { name: "non-existent tag", tagsFlag: []string{"nonexistent"}, expectedCount: 0, expectedNames: []string{}, expectError: true, }, { name: "invalid tags expression", tagsExprFlag: "ui && (NOT", // Invalid syntax expectedCount: 0, expectError: true, }, { name: "cwd flag with other flags", cwdFlag: true, projectsFlag: []string{"root"}, expectedCount: 1, expectedNames: []string{""}, expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { projects, err := config.FilterProjects( tt.cwdFlag, tt.allProjectsFlag, tt.projectsFlag, tt.projectPathsFlag, tt.tagsFlag, tt.tagsExprFlag, ) // Check error expectations if tt.expectError && err == nil { t.Error("expected error but got none") } if !tt.expectError && err != nil { t.Errorf("unexpected error: %v", err) } // Skip further checks if we expected an error if tt.expectError { return } // Check number of projects returned if len(projects) != tt.expectedCount { t.Errorf("expected %d projects, got %d", tt.expectedCount, len(projects)) } // Check specific projects returned (if specified) if tt.expectedNames != nil { actualNames := make([]string, len(projects)) for i, p := range projects { actualNames[i] = p.Name } // Sort both slices to ensure consistent comparison sort.Strings(actualNames) sort.Strings(tt.expectedNames) if !reflect.DeepEqual(actualNames, tt.expectedNames) { t.Errorf("expected projects %v, got %v", tt.expectedNames, actualNames) } } }) } } ================================================ FILE: core/dao/theme.go ================================================ package dao import ( "strings" "golang.org/x/text/cases" "golang.org/x/text/language" "gopkg.in/yaml.v3" "github.com/alajmo/mani/core" "github.com/gookit/color" ) type ColorOptions struct { Fg *string `yaml:"fg"` Bg *string `yaml:"bg"` Align *string `yaml:"align"` Attr *string `yaml:"attr"` Format *string `yaml:"format"` } type Theme struct { Name string `yaml:"name"` Table Table `yaml:"table"` Tree Tree `yaml:"tree"` Stream Stream `yaml:"stream"` Block Block `yaml:"block"` TUI TUI `yaml:"tui"` Color *bool `yaml:"color"` context string contextLine int } type Row struct { Columns []string } type TableOutput struct { Headers []string Rows []Row } func (t *Theme) GetContext() string { return t.context } func (t *Theme) GetContextLine() int { return t.contextLine } func (r Row) GetValue(_ string, i int) string { if i < len(r.Columns) { return r.Columns[i] } return "" } // Populates ThemeList func (c *Config) ParseThemes() ([]Theme, []ResourceErrors[Theme]) { var themes []Theme count := len(c.Themes.Content) themeErrors := []ResourceErrors[Theme]{} foundErrors := false for i := 0; i < count; i += 2 { theme := &Theme{ Name: c.Themes.Content[i].Value, context: c.Path, contextLine: c.Themes.Content[i].Line, } err := c.Themes.Content[i+1].Decode(theme) if err != nil { foundErrors = true themeError := ResourceErrors[Theme]{Resource: theme, Errors: core.StringsToErrors(err.(*yaml.TypeError).Errors)} themeErrors = append(themeErrors, themeError) continue } themes = append(themes, *theme) } // Loop through themes and set default values for i := range themes { // Color if themes[i].Color == nil { themes[i].Color = core.Ptr(true) } // Stream LoadStreamTheme(&themes[i].Stream) // Table LoadTableTheme(&themes[i].Table) // Tree LoadTreeTheme(&themes[i].Tree) // Block LoadBlockTheme(&themes[i].Block) // TUI LoadTUITheme(&themes[i].TUI) } if foundErrors { return themes, themeErrors } return themes, nil } func (c Config) GetTheme(name string) (*Theme, error) { for _, theme := range c.ThemeList { if name == theme.Name { return &theme, nil } } return nil, &core.ThemeNotFound{Name: name} } func (c Config) GetThemeNames() []string { names := []string{} for _, theme := range c.ThemeList { names = append(names, theme.Name) } return names } // Merges default with user theme. // Converts colors to hex, and align, attr, and format to its backend representation (single character). func MergeThemeOptions(userOption *ColorOptions, defaultOption *ColorOptions) *ColorOptions { if userOption == nil { return &ColorOptions{ Fg: convertToHex(defaultOption.Fg), Bg: convertToHex(defaultOption.Bg), Attr: convertToAttr(defaultOption.Attr), Align: convertToAlign(defaultOption.Align), Format: convertToFormat(defaultOption.Format), } } result := &ColorOptions{} if userOption.Fg == nil { result.Fg = convertToHex(defaultOption.Fg) } else { result.Fg = convertToHex(userOption.Fg) } if userOption.Bg == nil { result.Bg = convertToHex(defaultOption.Bg) } else { result.Bg = convertToHex(userOption.Bg) } if userOption.Attr == nil { result.Attr = convertToAttr(defaultOption.Attr) } else { result.Attr = convertToAttr(userOption.Attr) } if userOption.Align == nil { result.Align = convertToAlign(defaultOption.Align) } else { result.Align = convertToAlign(userOption.Align) } if userOption.Format == nil { result.Format = convertToFormat(defaultOption.Format) } else { result.Format = convertToFormat(userOption.Format) } return result } // Used for gookit/color printing stream func StyleFg(colr string) color.RGBColor { // User provided if colr != "" { return color.HEX(colr) } // Default Fg color return color.Normal.RGB() } func StyleFormat(text string, format string) string { switch format { case "l": return strings.ToLower(text) case "u": return strings.ToUpper(text) case "t": caser := cases.Title(language.English) return caser.String(text) } return text } // Used for gookit/color printing tables/blocks func StyleString(text string, opts ColorOptions, useColors bool) string { if !useColors { return text } // Format switch *opts.Format { case "l": text = strings.ToLower(text) case "u": text = strings.ToUpper(text) case "t": caser := cases.Title(language.English) text = caser.String(text) } // Fg var fgStr string if *opts.Fg != "" { fgStr = color.HEX(*opts.Fg).Sprint(text) } else { fgStr = text } // Attr attr := color.OpReset switch *opts.Attr { case "b": attr = color.OpBold case "i": attr = color.OpItalic case "u": attr = color.OpUnderscore } styledString := attr.Sprint(fgStr) return styledString } func convertToHex(s *string) *string { if s == nil || len(*s) == 0 { return core.Ptr("-") } // Assume it's hex already if (*s)[0] == '#' { return s } // Named color hex := "#" + color.RGBFromString(*s).Hex() return &hex } func convertToAttr(attr *string) *string { if attr == nil || len(*attr) == 0 { return core.Ptr("-") } attrStr := strings.ToLower(*attr) switch attrStr { case "b", "bold": return core.Ptr("b") case "i", "italic": return core.Ptr("i") case "u", "underline": return core.Ptr("u") } return core.Ptr("-") } func convertToAlign(align *string) *string { if align == nil || len(*align) == 0 { return core.Ptr("") } alignStr := strings.ToLower(*align) switch alignStr { case "l", "left": return core.Ptr("l") case "c", "center": return core.Ptr("c") case "r", "right": return core.Ptr("r") } return core.Ptr("") } func convertToFormat(format *string) *string { if format == nil || len(*format) == 0 { return core.Ptr("") } formatStr := strings.ToLower(*format) switch formatStr { case "t", "title": return core.Ptr("t") case "l", "lower": return core.Ptr("l") case "u", "upper": return core.Ptr("u") } return core.Ptr("") } ================================================ FILE: core/dao/theme_block.go ================================================ package dao import ( "github.com/alajmo/mani/core" ) var DefaultBlock = Block{ Key: &ColorOptions{ Fg: core.Ptr("#5f87d7"), Attr: core.Ptr(""), Format: core.Ptr(""), }, Separator: &ColorOptions{ Fg: core.Ptr("#5f87d7"), Attr: core.Ptr(""), Format: core.Ptr(""), }, Value: &ColorOptions{ Fg: core.Ptr(""), Attr: core.Ptr(""), Format: core.Ptr(""), }, ValueTrue: &ColorOptions{ Fg: core.Ptr("#00af5f"), Attr: core.Ptr(""), Format: core.Ptr(""), }, ValueFalse: &ColorOptions{ Fg: core.Ptr("#d75f5f"), Attr: core.Ptr(""), Format: core.Ptr(""), }, } type Block struct { Key *ColorOptions `yaml:"key"` Separator *ColorOptions `yaml:"separator"` Value *ColorOptions `yaml:"value"` ValueTrue *ColorOptions `yaml:"value_true"` ValueFalse *ColorOptions `yaml:"value_false"` } func LoadBlockTheme(block *Block) { if block.Key == nil { block.Key = DefaultBlock.Key } else { block.Key = MergeThemeOptions(block.Key, DefaultBlock.Key) } if block.Value == nil { block.Value = DefaultBlock.Value } else { block.Value = MergeThemeOptions(block.Value, DefaultBlock.Value) } if block.Separator == nil { block.Separator = DefaultBlock.Separator } else { block.Separator = MergeThemeOptions(block.Separator, DefaultBlock.Separator) } if block.ValueTrue == nil { block.ValueTrue = DefaultBlock.ValueTrue } else { block.ValueTrue = MergeThemeOptions(block.ValueTrue, DefaultBlock.ValueTrue) } if block.ValueFalse == nil { block.ValueFalse = DefaultBlock.ValueFalse } else { block.ValueFalse = MergeThemeOptions(block.ValueFalse, DefaultBlock.ValueFalse) } } ================================================ FILE: core/dao/theme_stream.go ================================================ package dao type Stream struct { Prefix bool `yaml:"prefix"` PrefixColors []string `yaml:"prefix_colors"` Header bool `yaml:"header"` HeaderChar string `yaml:"header_char"` HeaderPrefix string `yaml:"header_prefix"` } var DefaultStream = Stream{ Prefix: true, Header: true, HeaderPrefix: "TASK", HeaderChar: "*", PrefixColors: []string{"#d787ff", "#00af5f", "#d75f5f", "#5f87d7", "#00af87", "#5f00ff"}, } func LoadStreamTheme(stream *Stream) { if stream.PrefixColors == nil { stream.PrefixColors = DefaultStream.PrefixColors } else { for j := range stream.PrefixColors { stream.PrefixColors[j] = *convertToHex(&stream.PrefixColors[j]) } } } ================================================ FILE: core/dao/theme_table.go ================================================ package dao import ( "strings" "github.com/jedib0t/go-pretty/v6/table" "github.com/alajmo/mani/core" ) var DefaultTable = Table{ Box: table.StyleDefault.Box, Style: "ascii", Border: &Border{ Around: core.Ptr(false), Columns: core.Ptr(true), Header: core.Ptr(true), Rows: core.Ptr(true), }, Header: &ColorOptions{ Fg: core.Ptr("#d787ff"), Attr: core.Ptr("bold"), Format: core.Ptr(""), }, TitleColumn: &ColorOptions{ Fg: core.Ptr("#5f87d7"), Attr: core.Ptr("bold"), Format: core.Ptr(""), }, } type Border struct { Around *bool `yaml:"around"` Columns *bool `yaml:"columns"` Header *bool `yaml:"header"` Rows *bool `yaml:"rows"` } type Table struct { // Stylable via YAML Style string `yaml:"style"` Border *Border `yaml:"border"` Header *ColorOptions `yaml:"header"` TitleColumn *ColorOptions `yaml:"title_column"` // Not stylable via YAML Box table.BoxStyle `yaml:"-"` } func LoadTableTheme(mTable *Table) { // Table style := strings.ToLower(mTable.Style) switch style { case "light": mTable.Box = table.StyleLight.Box case "bold": mTable.Box = table.StyleBold.Box case "double": mTable.Box = table.StyleDouble.Box case "rounded": mTable.Box = table.StyleRounded.Box default: // ascii mTable.Box = table.StyleBoxDefault } // Options if mTable.Border == nil { mTable.Border = DefaultTable.Border } else { if mTable.Border.Around == nil { mTable.Border.Around = DefaultTable.Border.Around } if mTable.Border.Columns == nil { mTable.Border.Columns = DefaultTable.Border.Columns } if mTable.Border.Header == nil { mTable.Border.Header = DefaultTable.Border.Header } if mTable.Border.Rows == nil { mTable.Border.Rows = DefaultTable.Border.Rows } } // Header if mTable.Header == nil { mTable.Header = DefaultTable.Header } else { mTable.Header = MergeThemeOptions(mTable.Header, DefaultTable.Header) } // Title Column if mTable.TitleColumn == nil { mTable.TitleColumn = DefaultTable.TitleColumn } else { mTable.TitleColumn = MergeThemeOptions(mTable.TitleColumn, DefaultTable.TitleColumn) } } ================================================ FILE: core/dao/theme_tree.go ================================================ package dao import ( "strings" ) type Tree struct { Style string `yaml:"style"` } var DefaultTree = Tree{ Style: "light", } func LoadTreeTheme(tree *Tree) { style := strings.ToLower(tree.Style) switch style { case "light": tree.Style = "light" case "bullet-flower": tree.Style = "bullet-flower" case "bullet-square": tree.Style = "bullet-square" case "bullet-star": tree.Style = "bullet-star" case "bullet-triangle": tree.Style = "bullet-triangle" case "bold": tree.Style = "bold" case "double": tree.Style = "double" case "rounded": tree.Style = "rounded" case "markdown": tree.Style = "markdown" default: tree.Style = "ascii" } } ================================================ FILE: core/dao/theme_tui.go ================================================ package dao import ( "github.com/alajmo/mani/core" ) // DefaultTUI Not all attributes are used, but no clean way to add them since // MergeThemeOptions initializes all of the fields. var DefaultTUI = TUI{ Default: &ColorOptions{ Fg: core.Ptr(""), Bg: core.Ptr(""), Attr: core.Ptr(""), Format: core.Ptr(""), }, Border: &ColorOptions{ Fg: core.Ptr(""), Bg: core.Ptr(""), Attr: core.Ptr(""), Format: core.Ptr(""), }, BorderFocus: &ColorOptions{ Fg: core.Ptr("#d787ff"), Bg: core.Ptr(""), Attr: core.Ptr(""), Format: core.Ptr(""), }, Title: &ColorOptions{ Fg: core.Ptr(""), Bg: core.Ptr(""), Attr: core.Ptr(""), Align: core.Ptr("center"), Format: core.Ptr(""), }, TitleActive: &ColorOptions{ Fg: core.Ptr("#000000"), Bg: core.Ptr("#d787ff"), Attr: core.Ptr(""), Align: core.Ptr("center"), Format: core.Ptr(""), }, Button: &ColorOptions{ Fg: core.Ptr(""), Bg: core.Ptr(""), Attr: core.Ptr(""), Align: core.Ptr(""), Format: core.Ptr(""), }, ButtonActive: &ColorOptions{ Fg: core.Ptr("#080808"), Bg: core.Ptr("#d787ff"), Attr: core.Ptr(""), Align: core.Ptr(""), Format: core.Ptr(""), }, Item: &ColorOptions{ Fg: core.Ptr(""), Bg: core.Ptr(""), Attr: core.Ptr(""), Format: core.Ptr(""), }, ItemFocused: &ColorOptions{ Fg: core.Ptr("#ffffff"), Bg: core.Ptr("#262626"), Attr: core.Ptr(""), Format: core.Ptr(""), }, ItemSelected: &ColorOptions{ Fg: core.Ptr("#5f87d7"), Bg: core.Ptr(""), Attr: core.Ptr(""), Format: core.Ptr(""), }, ItemDir: &ColorOptions{ Fg: core.Ptr("#d787ff"), Bg: core.Ptr(""), Attr: core.Ptr(""), Format: core.Ptr(""), }, ItemRef: &ColorOptions{ Fg: core.Ptr("#d787ff"), Bg: core.Ptr(""), Attr: core.Ptr(""), Format: core.Ptr(""), }, TableHeader: &ColorOptions{ Fg: core.Ptr("#d787ff"), Bg: core.Ptr(""), Attr: core.Ptr("bold"), Align: core.Ptr("left"), Format: core.Ptr(""), }, SearchLabel: &ColorOptions{ Fg: core.Ptr("#d7d75f"), Bg: core.Ptr(""), Attr: core.Ptr("bold"), Format: core.Ptr(""), }, SearchText: &ColorOptions{ Fg: core.Ptr(""), Bg: core.Ptr(""), Attr: core.Ptr(""), Format: core.Ptr(""), }, FilterLabel: &ColorOptions{ Fg: core.Ptr("#d7d75f"), Bg: core.Ptr(""), Attr: core.Ptr("bold"), Format: core.Ptr(""), }, FilterText: &ColorOptions{ Fg: core.Ptr(""), Bg: core.Ptr(""), Attr: core.Ptr(""), Format: core.Ptr(""), }, ShortcutLabel: &ColorOptions{ Fg: core.Ptr("#00af5f"), Bg: core.Ptr(""), Attr: core.Ptr(""), Format: core.Ptr(""), }, ShortcutText: &ColorOptions{ Fg: core.Ptr(""), Bg: core.Ptr(""), Attr: core.Ptr(""), Format: core.Ptr(""), }, } type TUI struct { Default *ColorOptions `yaml:"default"` Border *ColorOptions `yaml:"border"` BorderFocus *ColorOptions `yaml:"border_focus"` Title *ColorOptions `yaml:"title"` TitleActive *ColorOptions `yaml:"title_active"` TableHeader *ColorOptions `yaml:"table_header"` Item *ColorOptions `yaml:"item"` ItemFocused *ColorOptions `yaml:"item_focused"` ItemSelected *ColorOptions `yaml:"item_selected"` ItemDir *ColorOptions `yaml:"item_dir"` ItemRef *ColorOptions `yaml:"item_ref"` Button *ColorOptions `yaml:"button"` ButtonActive *ColorOptions `yaml:"button_active"` SearchLabel *ColorOptions `yaml:"search_label"` SearchText *ColorOptions `yaml:"search_text"` FilterLabel *ColorOptions `yaml:"filter_label"` FilterText *ColorOptions `yaml:"filter_text"` ShortcutLabel *ColorOptions `yaml:"shortcut_label"` ShortcutText *ColorOptions `yaml:"shortcut_text"` } func LoadTUITheme(tui *TUI) { tui.Default = MergeThemeOptions(tui.Default, DefaultTUI.Default) tui.Border = MergeThemeOptions(tui.Border, DefaultTUI.Border) tui.BorderFocus = MergeThemeOptions(tui.BorderFocus, DefaultTUI.BorderFocus) tui.Button = MergeThemeOptions(tui.Button, DefaultTUI.Button) tui.ButtonActive = MergeThemeOptions(tui.ButtonActive, DefaultTUI.ButtonActive) tui.Item = MergeThemeOptions(tui.Item, DefaultTUI.Item) tui.ItemFocused = MergeThemeOptions(tui.ItemFocused, DefaultTUI.ItemFocused) tui.ItemSelected = MergeThemeOptions(tui.ItemSelected, DefaultTUI.ItemSelected) tui.ItemDir = MergeThemeOptions(tui.ItemDir, DefaultTUI.ItemDir) tui.ItemRef = MergeThemeOptions(tui.ItemRef, DefaultTUI.ItemRef) tui.Title = MergeThemeOptions(tui.Title, DefaultTUI.Title) tui.TitleActive = MergeThemeOptions(tui.TitleActive, DefaultTUI.TitleActive) tui.TableHeader = MergeThemeOptions(tui.TableHeader, DefaultTUI.TableHeader) tui.SearchLabel = MergeThemeOptions(tui.SearchLabel, DefaultTUI.SearchLabel) tui.SearchText = MergeThemeOptions(tui.SearchText, DefaultTUI.SearchText) tui.FilterLabel = MergeThemeOptions(tui.FilterLabel, DefaultTUI.FilterLabel) tui.FilterText = MergeThemeOptions(tui.FilterText, DefaultTUI.FilterText) tui.ShortcutText = MergeThemeOptions(tui.ShortcutText, DefaultTUI.ShortcutText) tui.ShortcutLabel = MergeThemeOptions(tui.ShortcutLabel, DefaultTUI.ShortcutLabel) } ================================================ FILE: core/dao/utils_test.go ================================================ package dao import ( "reflect" "sort" ) // Helper functions func getProjectNames(projects []Project) []string { names := make([]string, len(projects)) for i, p := range projects { names[i] = p.Name } sort.Strings(names) return names } func getTreePaths(nodes []TreeNode) []string { paths := make([]string, len(nodes)) for i, node := range nodes { paths[i] = node.Path } sort.Strings(paths) return paths } func equalStringSlices(a, b []string) bool { if len(a) != len(b) { return false } aCopy := make([]string, len(a)) bCopy := make([]string, len(b)) copy(aCopy, a) copy(bCopy, b) sort.Strings(aCopy) sort.Strings(bCopy) return reflect.DeepEqual(aCopy, bCopy) } ================================================ FILE: core/errors.go ================================================ package core import ( "fmt" "os" "strings" "github.com/gookit/color" ) type ConfigEnvFailed struct { Name string Err string } func (c *ConfigEnvFailed) Error() string { return fmt.Sprintf("failed to evaluate env `%s` \n %s", c.Name, c.Err) } type AlreadyManiDirectory struct { Dir string } func (c *AlreadyManiDirectory) Error() string { return fmt.Sprintf("`%s` is already a mani directory\n", c.Dir) } type ZeroNotAllowed struct { Name string } func (c *ZeroNotAllowed) Error() string { return fmt.Sprintf("invalid value for %s, cannot be 0", c.Name) } type FailedToOpenFile struct { Name string } func (f *FailedToOpenFile) Error() string { return fmt.Sprintf("failed to open `%s`", f.Name) } type FailedToParsePath struct { Name string } func (f *FailedToParsePath) Error() string { return fmt.Sprintf("failed to parse path `%s`", f.Name) } type PathDoesNotExist struct { Path string } func (p *PathDoesNotExist) Error() string { return fmt.Sprintf("path `%s` does not exist", p.Path) } type TagNotFound struct { Tags []string } func (c *TagNotFound) Error() string { tags := "`" + strings.Join(c.Tags, "`, `") + "`" return fmt.Sprintf("cannot find tags %s", tags) } type DirNotFound struct { Dirs []string } func (c *DirNotFound) Error() string { dirs := "`" + strings.Join(c.Dirs, "`, `") + "`" return fmt.Sprintf("cannot find paths %s", dirs) } type NoTargets struct{} func (c *NoTargets) Error() string { return "no matching projects found" } type ProjectNotFound struct { Name []string } func (c *ProjectNotFound) Error() string { projects := "`" + strings.Join(c.Name, "`, `") + "`" return fmt.Sprintf("cannot find projects %s", projects) } type TaskNotFound struct { Name []string } func (c *TaskNotFound) Error() string { tasks := "`" + strings.Join(c.Name, "`, `") + "`" return fmt.Sprintf("cannot find tasks %s", tasks) } type ThemeNotFound struct { Name string } func (c *ThemeNotFound) Error() string { return fmt.Sprintf("cannot find theme `%s`", c.Name) } type SpecNotFound struct { Name string } func (c *SpecNotFound) Error() string { return fmt.Sprintf("cannot find spec `%s`", c.Name) } type SpecOutputError struct { Name string Output string } func (c *SpecOutputError) Error() string { return fmt.Sprintf("invalid output for spec `%s`, found `%s`, expected one of: stream, table, html, markdown", c.Name, c.Output) } type TargetNotFound struct { Name string } func (c *TargetNotFound) Error() string { return fmt.Sprintf("cannot find target `%s`", c.Name) } type TargetTagsExprError struct { Name string Err error } func (c *TargetTagsExprError) Error() string { return fmt.Sprintf("invalid tags_expr for target `%s`, %s", c.Name, c.Err.Error()) } type TagExprInvalid struct { Expression string } func (c *TagExprInvalid) Error() string { return fmt.Sprintf("invalid tags expression: %s", c.Expression) } type ConfigNotFound struct { Names []string } func (f *ConfigNotFound) Error() string { return fmt.Sprintf("cannot find any configuration file %v in current directory or any of the parent directories", f.Names) } type WorktreePathRequired struct{} func (c *WorktreePathRequired) Error() string { return "worktree path is required" } type FailedToCreateWorktree struct { Path string Output string Err error } func (c *FailedToCreateWorktree) Error() string { return fmt.Sprintf("failed to create worktree `%s`: %s - %s", c.Path, c.Err, c.Output) } type FailedToRemoveWorktree struct { Path string Output string Err error } func (c *FailedToRemoveWorktree) Error() string { return fmt.Sprintf("failed to remove worktree `%s`: %s - %s", c.Path, c.Err, c.Output) } type ConfigErr struct { Msg string } func (f *ConfigErr) Error() string { return f.Msg } func CheckIfError(err error) { if err != nil { switch err.(type) { case *ConfigErr: // Errors are already mapped with `error:` prefix fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) default: fmt.Fprintf(os.Stderr, "%s: %v\n", color.FgRed.Sprintf("error"), err) os.Exit(1) } } } func Exit(err error) { switch err := err.(type) { case *ConfigErr: // Errors are already mapped with `error:` prefix fmt.Fprintf(os.Stderr, "%v", err) os.Exit(1) default: fmt.Fprintf(os.Stderr, "%s: %v\n", color.FgRed.Sprintf("error"), err) os.Exit(1) } } ================================================ FILE: core/exec/client.go ================================================ package exec import ( "fmt" "io" "os" "os/exec" ) // Client is a wrapper over the SSH connection/sessions. type Client struct { Name string Path string Env []string cmd *exec.Cmd stdout io.Reader stderr io.Reader running bool } func (c *Client) Run(shell string, env []string, cmdStr []string) error { var err error if c.running { return fmt.Errorf("command already running") } cmd := exec.Command(shell, cmdStr...) cmd.Dir = c.Path cmd.Env = append(os.Environ(), env...) c.cmd = cmd c.stdout, err = cmd.StdoutPipe() if err != nil { return err } c.stderr, err = cmd.StderrPipe() if err != nil { return err } if err := c.cmd.Start(); err != nil { return err } c.running = true return nil } func (c *Client) Wait() error { if !c.running { return fmt.Errorf("trying to wait on stopped command") } err := c.cmd.Wait() c.running = false return err } func (c *Client) Close() error { return nil } func (c *Client) Stderr() io.Reader { return c.stderr } func (c *Client) Stdout() io.Reader { return c.stdout } func (c *Client) Prefix() string { return c.Name } ================================================ FILE: core/exec/clone.go ================================================ package exec import ( "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/print" "github.com/gookit/color" ) func getRemotes(project dao.Project) (map[string]string, error) { cmd := exec.Command("git", "remote", "-v") cmd.Dir = project.Path output, err := cmd.CombinedOutput() if err != nil { return nil, err } outputStr := string(output) lines := strings.Split(outputStr, "\n") remotes := make(map[string]string) for _, line := range lines { if line == "" { continue } parts := strings.Fields(line) if len(parts) < 3 { return nil, fmt.Errorf("unexpected line: %s", line) } remotes[parts[0]] = parts[1] } return remotes, nil } func addRemote(project dao.Project, remote dao.Remote) error { cmd := exec.Command("git", "remote", "add", remote.Name, remote.URL) cmd.Dir = project.Path _, err := cmd.CombinedOutput() if err != nil { return err } return nil } func removeRemote(project dao.Project, name string) error { cmd := exec.Command("git", "remote", "remove", name) cmd.Dir = project.Path _, err := cmd.CombinedOutput() if err != nil { return err } return nil } func updateRemote(project dao.Project, remote dao.Remote) error { cmd := exec.Command("git", "remote", "set-url", remote.Name, remote.URL) cmd.Dir = project.Path _, err := cmd.CombinedOutput() if err != nil { return err } return nil } func syncRemotes(project dao.Project) error { foundRemotes, err := getRemotes(project) if err != nil { return err } // Add remotes found in RemoteList but not in .git/config for _, remote := range project.RemoteList { _, found := foundRemotes[remote.Name] if found { err := updateRemote(project, remote) if err != nil { return err } } else { err := addRemote(project, remote) if err != nil { return err } } } // Don't remove remotes if project url is empty if project.URL == "" { return nil } // Remove remotes found in .git/config but not in RemoteList for name, foundURL := range foundRemotes { // Ignore origin remote (same as project url) if foundURL == project.URL { continue } // Check if this URL exists in project.RemoteList urlExists := false for _, remote := range project.RemoteList { if foundURL == remote.URL { urlExists = true break } } // If URL is not in RemoteList, remove the remote if !urlExists { err := removeRemote(project, name) if err != nil { return err } } } return nil } // CreateWorktree creates a git worktree at the specified path for the given branch. // If the branch doesn't exist, it creates a new branch. func CreateWorktree(parentPath string, worktreePath string, branch string, createBranch bool) error { var cmd *exec.Cmd if createBranch { cmd = exec.Command("git", "worktree", "add", "-b", branch, worktreePath) } else { cmd = exec.Command("git", "worktree", "add", worktreePath, branch) } cmd.Dir = parentPath output, err := cmd.CombinedOutput() if err != nil { return &core.FailedToCreateWorktree{Path: worktreePath, Err: err, Output: string(output)} } return nil } // GetWorktrees returns a map of existing worktrees (path -> branch) func GetWorktrees(parentPath string) (map[string]string, error) { return core.GetWorktreeList(parentPath) } // RemoveWorktree removes a git worktree (keeps the branch) func RemoveWorktree(parentPath string, worktreePath string) error { cmd := exec.Command("git", "worktree", "remove", worktreePath) cmd.Dir = parentPath output, err := cmd.CombinedOutput() if err != nil { return &core.FailedToRemoveWorktree{Path: worktreePath, Err: err, Output: string(output)} } return nil } // SyncWorktrees handles worktree creation and optionally removal for a project func SyncWorktrees(config *dao.Config, project dao.Project, removeOrphans bool) error { parentPath, err := core.GetAbsolutePath(config.Path, project.Path, project.Name) if err != nil { return err } // Parent must exist first (skip if not cloned yet) if _, err := os.Stat(parentPath); os.IsNotExist(err) { return nil } // Prune stale worktree references (e.g. from manually deleted directories) pruneCmd := exec.Command("git", "worktree", "prune") pruneCmd.Dir = parentPath _ = pruneCmd.Run() // Build map of expected worktree paths from config expectedPaths := make(map[string]bool) for _, wt := range project.WorktreeList { var wtPath string if filepath.IsAbs(wt.Path) { wtPath = filepath.Clean(wt.Path) } else { wtPath = filepath.Join(parentPath, wt.Path) } expectedPaths[wtPath] = true // Create worktree if it doesn't exist if _, err := os.Stat(wtPath); os.IsNotExist(err) { // Try checking out existing branch first (local or remote-tracking) err = CreateWorktree(parentPath, wtPath, wt.Branch, false) if err != nil { // Branch doesn't exist anywhere — create it err = CreateWorktree(parentPath, wtPath, wt.Branch, true) } if err != nil { return err } } } // Remove worktrees not in config (only if enabled) if removeOrphans { existingWorktrees, err := GetWorktrees(parentPath) if err != nil { return err } for wtPath := range existingWorktrees { if !expectedPaths[wtPath] { err := RemoveWorktree(parentPath, wtPath) if err != nil { return err } } } } return nil } func CloneRepos(config *dao.Config, projects []dao.Project, syncFlags core.SyncFlags) error { urls := config.GetProjectUrls() if len(urls) == 0 { fmt.Println("No projects to clone") return nil } var syncProjects []dao.Project for i := range projects { if !syncFlags.IgnoreSyncState && !projects[i].IsSync() { continue } if projects[i].URL == "" { continue } projectPath, err := core.GetAbsolutePath(config.Path, projects[i].Path, projects[i].Name) if err != nil { return err } // Project already synced if _, err := os.Stat(projectPath); !os.IsNotExist(err) { continue } syncProjects = append(syncProjects, projects[i]) } var tasks []dao.Task for i := range syncProjects { var cmd string var cmdArr []string var shell string var shellProgram string if syncProjects[i].Clone != "" { shell = dao.DEFAULT_SHELL shellProgram = dao.DEFAULT_SHELL_PROGRAM cmdArr = []string{"-c", syncProjects[i].Clone} cmd = syncProjects[i].Clone } else { projectPath, err := core.GetAbsolutePath(config.Path, syncProjects[i].Path, syncProjects[i].Name) if err != nil { return err } shell = "git" shellProgram = "git" if syncFlags.Parallel { cmdArr = []string{"clone", syncProjects[i].URL, projectPath} } else { cmdArr = []string{"clone", "--progress", syncProjects[i].URL, projectPath} } if syncProjects[i].Branch != "" { cmdArr = append(cmdArr, "--branch", syncProjects[i].Branch) } if syncProjects[i].IsSingleBranch() { cmdArr = append(cmdArr, "--single-branch") } cmd = strings.Join(cmdArr, " ") } if len(syncProjects) > 0 { var task = dao.Task{ Name: syncProjects[i].Name, Shell: shell, Cmd: cmd, ShellProgram: shellProgram, CmdArg: cmdArr, SpecData: dao.Spec{ Parallel: syncFlags.Parallel, Forks: syncFlags.Forks, IgnoreErrors: false, }, ThemeData: dao.Theme{ Color: core.Ptr(true), Stream: dao.Stream{ Prefix: syncFlags.Parallel, // we only use prefix when parallel is enabled since we need to see which project returns an error Header: true, HeaderChar: dao.DefaultStream.HeaderChar, HeaderPrefix: "Project", PrefixColors: dao.DefaultStream.PrefixColors, }, }, } tasks = append(tasks, task) } } if len(syncProjects) > 0 { target := Exec{Projects: syncProjects, Tasks: tasks, Config: *config} clientCh := make(chan Client, len(syncProjects)) err := target.SetCloneClients(clientCh) if err != nil { return err } target.Text(false, os.Stdout, os.Stderr) } // User has opt-in to Sync remotes if *config.SyncRemotes { for i := range projects { // Project must have a Remote List defined if len(projects[i].RemoteList) > 0 { err := syncRemotes(projects[i]) if err != nil { return err } } } } // Sync worktrees: create if defined, remove orphans if enabled for i := range projects { if len(projects[i].WorktreeList) > 0 || *config.RemoveOrphanedWorktrees { err := SyncWorktrees(config, projects[i], *config.RemoveOrphanedWorktrees) if err != nil { return err } } } return nil } func UpdateGitignoreIfExists(config *dao.Config) error { // Only add projects to gitignore if a .gitignore file exists in the mani.yaml directory gitignoreFilename := filepath.Join(filepath.Dir(config.Path), ".gitignore") if _, err := os.Stat(gitignoreFilename); err == nil { // Get relative project names for gitignore file var gitignoreEntries []string for _, project := range config.ProjectList { if project.URL == "" { continue } // Project must be below mani config file to be added to gitignore var projectPath string projectPath, err = core.GetAbsolutePath(config.Path, project.Path, project.Name) if err != nil { return err } // Skip the root project (it is the mani directory itself) if projectPath == config.Dir { continue } if !strings.HasPrefix(projectPath, config.Dir) { continue } if project.Path != "" { var relPath string relPath, err = filepath.Rel(config.Dir, projectPath) if err != nil { return err } gitignoreEntries = append(gitignoreEntries, relPath) } else { gitignoreEntries = append(gitignoreEntries, project.Name) } // Add worktrees to gitignore as well for _, wt := range project.WorktreeList { var wtAbsPath string if filepath.IsAbs(wt.Path) { wtAbsPath = filepath.Clean(wt.Path) } else { wtAbsPath = filepath.Join(projectPath, wt.Path) } // Worktree must be below mani config file to be added to gitignore if !strings.HasPrefix(wtAbsPath, config.Dir) { continue } wtRelPath, err := filepath.Rel(config.Dir, wtAbsPath) if err != nil { continue } gitignoreEntries = append(gitignoreEntries, wtRelPath) } } err := dao.UpdateProjectsToGitignore(gitignoreEntries, gitignoreFilename) if err != nil { return err } } return nil } func (exec *Exec) SetCloneClients(clientCh chan Client) error { config := exec.Config projects := exec.Projects var clients []Client for i, project := range projects { func(i int, project dao.Project) { client := Client{ Path: config.Dir, Name: project.Name, Env: projects[i].EnvList, } clientCh <- client clients = append(clients, client) }(i, project) } close(clientCh) exec.Clients = clients return nil } func PrintProjectStatus(config *dao.Config, projects []dao.Project) error { theme := dao.Theme{ Color: core.Ptr(true), Table: dao.DefaultTable, } theme.Table.Border.Rows = core.Ptr(false) theme.Table.Header.Format = core.Ptr("t") options := print.PrintTableOptions{ Theme: theme, Output: "table", Color: *theme.Color, AutoWrap: true, OmitEmptyRows: false, OmitEmptyColumns: false, } data := dao.TableOutput{ Headers: []string{"project", "synced"}, Rows: []dao.Row{}, } for _, project := range projects { projectPath, err := core.GetAbsolutePath(config.Path, project.Path, project.Name) if err != nil { return err } if _, err := os.Stat(projectPath); !os.IsNotExist(err) { // Project synced data.Rows = append(data.Rows, dao.Row{Columns: []string{project.Name, color.FgGreen.Sprintf("\u2713")}}) } else { // Project not synced data.Rows = append(data.Rows, dao.Row{Columns: []string{project.Name, color.FgRed.Sprintf("\u2715")}}) } } fmt.Println() print.PrintTable(data.Rows, options, data.Headers, []string{}, os.Stdout) fmt.Println() return nil } func PrintProjectInit(projects []dao.Project) { if len(projects) == 0 { return } theme := dao.Theme{ Table: dao.DefaultTable, Color: core.Ptr(true), } theme.Table.Border.Rows = core.Ptr(false) theme.Table.Header.Format = core.Ptr("t") options := print.PrintTableOptions{ Theme: theme, Output: "table", Color: true, AutoWrap: true, OmitEmptyRows: true, OmitEmptyColumns: false, } data := dao.TableOutput{ Headers: []string{"project", "path"}, Rows: []dao.Row{}, } for _, project := range projects { data.Rows = append(data.Rows, dao.Row{Columns: []string{project.Name, project.Path}}) } fmt.Println("\nFollowing projects were added to mani.yaml") fmt.Println() print.PrintTable(data.Rows, options, data.Headers, []string{}, os.Stdout) } ================================================ FILE: core/exec/exec.go ================================================ package exec import ( "fmt" "io" "os" "strings" "github.com/gookit/color" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/print" ) type Exec struct { Clients []Client Projects []dao.Project Tasks []dao.Task Config dao.Config } type TableCmd struct { rIndex int cIndex int client Client dryRun bool desc string name string shell string env []string cmd string cmdArr []string numTasks int } func (exec *Exec) Run( userArgs []string, runFlags *core.RunFlags, setRunFlags *core.SetRunFlags, ) error { projects := exec.Projects tasks := exec.Tasks err := exec.ParseTask(userArgs, runFlags, setRunFlags) if err != nil { return err } clientCh := make(chan Client, len(projects)) errCh := make(chan error, len(projects)) err = exec.SetClients(clientCh, errCh) if err != nil { return err } // Describe task if runFlags.Describe { out := print.PrintTaskBlock([]dao.Task{tasks[0]}, false, tasks[0].ThemeData.Block, print.GookitFormatter{}) fmt.Print(out) } exec.CheckTaskNoColor() switch tasks[0].SpecData.Output { case "table", "html", "markdown": fmt.Println("") data := exec.Table(runFlags) options := print.PrintTableOptions{ Theme: tasks[0].ThemeData, Output: tasks[0].SpecData.Output, Color: *tasks[0].ThemeData.Color, AutoWrap: true, OmitEmptyRows: tasks[0].SpecData.OmitEmptyRows, OmitEmptyColumns: tasks[0].SpecData.OmitEmptyColumns, } print.PrintTable(data.Rows, options, data.Headers[0:1], data.Headers[1:], os.Stdout) fmt.Println("") default: exec.Text(runFlags.DryRun, os.Stdout, os.Stderr) } return nil } func (exec *Exec) RunTUI( userArgs []string, runFlags *core.RunFlags, setRunFlags *core.SetRunFlags, output string, outWriter io.Writer, errWriter io.Writer, ) error { projects := exec.Projects err := exec.ParseTask(userArgs, runFlags, setRunFlags) if err != nil { return err } tasks := exec.Tasks clientCh := make(chan Client, len(projects)) errCh := make(chan error, len(projects)) err = exec.SetClients(clientCh, errCh) if err != nil { return err } data := dao.TableOutput{} switch output { case "table": data = exec.Table(runFlags) options := print.PrintTableOptions{ Theme: tasks[0].ThemeData, Output: tasks[0].SpecData.Output, Color: *tasks[0].ThemeData.Color, AutoWrap: false, OmitEmptyRows: tasks[0].SpecData.OmitEmptyRows, OmitEmptyColumns: tasks[0].SpecData.OmitEmptyColumns, } print.PrintTable(data.Rows, options, data.Headers[0:1], data.Headers[1:], outWriter) return nil default: exec.Text(runFlags.DryRun, outWriter, errWriter) } return err } func (exec *Exec) SetClients( clientCh chan Client, errCh chan error, ) error { config := exec.Config ignoreNonExisting := exec.Tasks[0].SpecData.IgnoreNonExisting projects := exec.Projects var clients []Client for i, project := range projects { func(i int, project dao.Project) { projectPath, err := core.GetAbsolutePath(config.Path, project.Path, project.Name) if err != nil { errCh <- &core.FailedToParsePath{Name: projectPath} return } if _, err := os.Stat(projectPath); os.IsNotExist(err) && !ignoreNonExisting { errCh <- &core.PathDoesNotExist{Path: projectPath} return } client := Client{Path: projectPath, Name: project.Name, Env: project.EnvList} clientCh <- client clients = append(clients, client) }(i, project) } close(clientCh) close(errCh) // Return if there's any errors for err := range errCh { return err } exec.Clients = clients return nil } // ParseTask processes and updates task configurations based on runtime flags and user arguments. // It handles theme, specification, environment variables, and execution settings for each task. // // The function performs these operations for each task: // 1. Evaluates configuration environment variables // 2. Updates theme if specified // 3. Updates spec settings if provided // 4. Applies runtime execution flags // 5. Processes environment variables for the task and its commands // // Environment variable processing order: // 1. Configuration level variables // 2. Task level variables // 3. Command level variables // 4. User provided arguments func (exec *Exec) ParseTask(userArgs []string, runFlags *core.RunFlags, setRunFlags *core.SetRunFlags) error { configEnv, err := dao.EvaluateEnv(exec.Config.EnvList) if err != nil { return err } for i := range exec.Tasks { // Update theme property if user flag is provided if runFlags.Theme != "" { theme, err := exec.Config.GetTheme(runFlags.Theme) if err != nil { return err } exec.Tasks[i].ThemeData = *theme } if runFlags.Spec != "" { spec, err := exec.Config.GetSpec(runFlags.Spec) if err != nil { return err } exec.Tasks[i].SpecData = *spec } // Update output property if user flag is provided if runFlags.Output != "" { exec.Tasks[i].SpecData.Output = runFlags.Output } // TTY if setRunFlags.TTY { exec.Tasks[i].TTY = runFlags.TTY } // Omit rows which provide empty output if setRunFlags.OmitEmptyRows { exec.Tasks[i].SpecData.OmitEmptyRows = runFlags.OmitEmptyRows } // Omit columns which provide empty output if setRunFlags.OmitEmptyColumns { exec.Tasks[i].SpecData.OmitEmptyColumns = runFlags.OmitEmptyColumns } if setRunFlags.IgnoreErrors { exec.Tasks[i].SpecData.IgnoreErrors = runFlags.IgnoreErrors } if setRunFlags.IgnoreNonExisting { exec.Tasks[i].SpecData.IgnoreNonExisting = runFlags.IgnoreNonExisting } // If parallel flag is set to true, then update task specs if setRunFlags.Parallel { exec.Tasks[i].SpecData.Parallel = runFlags.Parallel } if setRunFlags.Forks { exec.Tasks[i].SpecData.Forks = runFlags.Forks } // Parse env here instead of config since we're only interested in tasks run, and not all tasks. // Also, userArgs is not present in the config. envs, err := dao.ParseTaskEnv(exec.Tasks[i].Env, userArgs, []string{}, configEnv) if err != nil { return err } exec.Tasks[i].EnvList = envs // Set environment variables for sub-commands for j := range exec.Tasks[i].Commands { envs, err := dao.ParseTaskEnv(exec.Tasks[i].Commands[j].Env, userArgs, exec.Tasks[i].EnvList, configEnv) if err != nil { return err } exec.Tasks[i].Commands[j].EnvList = envs } } return nil } func (exec *Exec) CheckTaskNoColor() { task := exec.Tasks[0] for _, env := range task.EnvList { name := strings.Split(env, "=")[0] if name == "NO_COLOR" { color.Disable() break } } } ================================================ FILE: core/exec/table.go ================================================ package exec import ( "fmt" "io" "os" "os/signal" "strings" "sync" "time" "github.com/theckman/yacspin" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" ) func (exec *Exec) Table(runFlags *core.RunFlags) dao.TableOutput { task := exec.Tasks[0] clients := exec.Clients projects := exec.Projects var spinner *yacspin.Spinner var spinnerErr error go func() { if !runFlags.Silent { time.Sleep(500 * time.Millisecond) spinner, spinnerErr = initSpinner() } }() // In-case user interrupts, make sure spinner is stopped go func() { sigchan := make(chan os.Signal, 1) signal.Notify(sigchan, os.Interrupt) <-sigchan if !runFlags.Silent && spinner != nil && spinnerErr == nil { _ = spinner.Stop() } os.Exit(0) }() var data dao.TableOutput var dataMutex = sync.RWMutex{} /** ** Headers **/ data.Headers = append(data.Headers, "project") // Append Command names if set for _, subTask := range task.Commands { if subTask.Name != "" { data.Headers = append(data.Headers, subTask.Name) } else { data.Headers = append(data.Headers, "output") } } // Append Command name if set if task.Cmd != "" { if task.Name != "" { data.Headers = append(data.Headers, task.Name) } else { data.Headers = append(data.Headers, "output") } } // Populate the rows (project name is first cell, then commands and cmd output is set to empty string) for i, p := range projects { data.Rows = append(data.Rows, dao.Row{Columns: []string{p.Name}}) for range task.Commands { data.Rows[i].Columns = append(data.Rows[i].Columns, "") } if task.Cmd != "" { data.Rows[i].Columns = append(data.Rows[i].Columns, "") } } wg := core.NewSizedWaitGroup(task.SpecData.Forks) /** ** Values **/ for i, c := range clients { wg.Add() if task.SpecData.Parallel { go func(i int, c Client, wg *core.SizedWaitGroup) { defer wg.Done() _ = exec.TableWork(i, runFlags.DryRun, data, &dataMutex) }(i, c, &wg) } else { func(i int, c Client, wg *core.SizedWaitGroup) { defer wg.Done() _ = exec.TableWork(i, runFlags.DryRun, data, &dataMutex) }(i, c, &wg) } } wg.Wait() if !runFlags.Silent && spinner != nil && spinnerErr == nil { _ = spinner.Stop() } return data } func (exec *Exec) TableWork(rIndex int, dryRun bool, data dao.TableOutput, dataMutex *sync.RWMutex) error { client := exec.Clients[rIndex] task := exec.Tasks[rIndex] var wg sync.WaitGroup for j, cmd := range task.Commands { args := TableCmd{ rIndex: rIndex, cIndex: j + 1, client: client, dryRun: dryRun, shell: cmd.ShellProgram, env: cmd.EnvList, cmd: cmd.Cmd, cmdArr: cmd.CmdArg, } if cmd.TTY { return ExecTTY(cmd.Cmd, cmd.EnvList) } err := RunTableCmd(args, data, dataMutex, &wg) if err != nil && !task.SpecData.IgnoreErrors { return err } } if task.Cmd != "" { args := TableCmd{ rIndex: rIndex, cIndex: len(task.Commands) + 1, client: client, dryRun: dryRun, shell: task.ShellProgram, env: task.EnvList, cmd: task.Cmd, cmdArr: task.CmdArg, } if task.TTY { return ExecTTY(task.Cmd, task.EnvList) } err := RunTableCmd(args, data, dataMutex, &wg) if err != nil && !task.SpecData.IgnoreErrors { return err } } wg.Wait() return nil } func RunTableCmd(t TableCmd, data dao.TableOutput, dataMutex *sync.RWMutex, wg *sync.WaitGroup) error { combinedEnvs := dao.MergeEnvs(t.client.Env, t.env) if t.dryRun { data.Rows[t.rIndex].Columns[t.cIndex] = t.cmd return nil } err := t.client.Run(t.shell, combinedEnvs, t.cmdArr) if err != nil { return err } // Copy over commands STDOUT. var stdoutHandler = func(client Client) { defer wg.Done() dataMutex.Lock() out, err := io.ReadAll(client.Stdout()) data.Rows[t.rIndex].Columns[t.cIndex] = fmt.Sprintf("%s%s", data.Rows[t.rIndex].Columns[t.cIndex], strings.TrimSuffix(string(out), "\n")) dataMutex.Unlock() if err != nil && err != io.EOF { fmt.Fprintf(os.Stderr, "%v", err) } } wg.Add(1) go stdoutHandler(t.client) // Copy over tasks's STDERR. var stderrHandler = func(client Client) { defer wg.Done() dataMutex.Lock() out, err := io.ReadAll(client.Stderr()) data.Rows[t.rIndex].Columns[t.cIndex] = fmt.Sprintf("%s%s", data.Rows[t.rIndex].Columns[t.cIndex], strings.TrimSuffix(string(out), "\n")) dataMutex.Unlock() if err != nil && err != io.EOF { fmt.Fprintf(os.Stderr, "%v", err) } } wg.Add(1) go stderrHandler(t.client) wg.Wait() if err := t.client.Wait(); err != nil { data.Rows[t.rIndex].Columns[t.cIndex] = fmt.Sprintf("%s\n%s", data.Rows[t.rIndex].Columns[t.cIndex], err.Error()) return err } return nil } func initSpinner() (*yacspin.Spinner, error) { spinner, err := dao.TaskSpinner() if err != nil { return &spinner, err } err = spinner.Start() if err != nil { return &spinner, err } spinner.Message(" Running") return &spinner, nil } ================================================ FILE: core/exec/text.go ================================================ package exec import ( "bufio" "fmt" "io" "strings" "sync" "golang.org/x/term" "github.com/gookit/color" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" ) func (exec *Exec) Text( dryRun bool, stdout io.Writer, stderr io.Writer, ) { task := exec.Tasks[0] clients := exec.Clients prefixMaxLen := calcMaxPrefixLength(clients) wg := core.NewSizedWaitGroup(task.SpecData.Forks) for i, c := range clients { task := exec.Tasks[i] wg.Add() if task.SpecData.Parallel { go func(i int, c Client, wg *core.SizedWaitGroup) { defer wg.Done() _ = exec.TextWork(i, prefixMaxLen, dryRun, stdout, stderr) }(i, c, &wg) } else { func(i int, c Client, wg *core.SizedWaitGroup) { defer wg.Done() _ = exec.TextWork(i, prefixMaxLen, dryRun, stdout, stderr) }(i, c, &wg) } } wg.Wait() fmt.Fprintf(stdout, "\n") } func (exec *Exec) TextWork( rIndex int, prefixMaxLen int, dryRun bool, stdout io.Writer, stderr io.Writer, ) error { client := exec.Clients[rIndex] task := exec.Tasks[rIndex] prefix := getPrefixer(client, rIndex, prefixMaxLen, task.ThemeData.Stream, task.SpecData.Parallel) var numTasks int if task.Cmd != "" { numTasks = len(task.Commands) + 1 } else { numTasks = len(task.Commands) } var wg sync.WaitGroup for j, cmd := range task.Commands { args := TableCmd{ rIndex: rIndex, cIndex: j, client: client, dryRun: dryRun, shell: cmd.ShellProgram, env: cmd.EnvList, cmd: cmd.Cmd, cmdArr: cmd.CmdArg, desc: cmd.Desc, name: cmd.Name, numTasks: numTasks, } if cmd.TTY { return ExecTTY(cmd.Cmd, cmd.EnvList) } err := RunTextCmd(args, task.ThemeData.Stream, prefix, task.SpecData.Parallel, &wg, stdout, stderr) if err != nil && !task.SpecData.IgnoreErrors { return err } } if task.Cmd != "" { args := TableCmd{ rIndex: rIndex, cIndex: len(task.Commands), client: client, dryRun: dryRun, shell: task.ShellProgram, env: task.EnvList, cmd: task.Cmd, cmdArr: task.CmdArg, desc: task.Desc, name: task.Name, numTasks: numTasks, } if task.TTY { return ExecTTY(task.Cmd, task.EnvList) } err := RunTextCmd(args, task.ThemeData.Stream, prefix, task.SpecData.Parallel, &wg, stdout, stderr) if err != nil && !task.SpecData.IgnoreErrors { return err } } wg.Wait() return nil } func RunTextCmd( t TableCmd, textStyle dao.Stream, prefix string, parallel bool, wg *sync.WaitGroup, stdout io.Writer, stderr io.Writer, ) error { combinedEnvs := dao.MergeEnvs(t.client.Env, t.env) if textStyle.Header && !parallel { printHeader(stdout, t.cIndex, t.numTasks, t.name, t.desc, textStyle) } if t.dryRun { printCmd(prefix, t.cmd) return nil } err := t.client.Run(t.shell, combinedEnvs, t.cmdArr) if err != nil { return err } // Copy over commands STDOUT. go func(client Client) { defer wg.Done() var err error if prefix != "" { _, err = io.Copy(stdout, core.NewPrefixer(client.Stdout(), prefix)) } else { _, err = io.Copy(stdout, client.Stdout()) } if err != nil && err != io.EOF { fmt.Fprintf(stderr, "%s", err) } }(t.client) wg.Add(1) // Copy over tasks's STDERR. go func(client Client) { defer wg.Done() var err error if prefix != "" { _, err = io.Copy(stderr, core.NewPrefixer(client.Stderr(), prefix)) } else { _, err = io.Copy(stderr, client.Stderr()) } if err != nil && err != io.EOF { fmt.Fprintf(stderr, "%s", err) } }(t.client) wg.Add(1) wg.Wait() if err := t.client.Wait(); err != nil { if prefix != "" { fmt.Fprintf(stderr, "%s%s\n", prefix, err) } else { fmt.Fprintf(stderr, "%s\n", err) } return err } return nil } // TASK [pwd] ------------- func printHeader(stdout io.Writer, i int, numTasks int, name string, desc string, ts dao.Stream) { var header string prefixName := "" if name == "" { prefixName = color.Bold.Sprint("Command") } else { prefixName = color.Bold.Sprint(name) } var prefixPart1 string if numTasks > 1 { prefixPart1 = fmt.Sprintf("%s (%d/%d)", color.Bold.Sprint(ts.HeaderPrefix), i+1, numTasks) } else { prefixPart1 = color.Bold.Sprint(ts.HeaderPrefix) } var prefixPart2 string if desc != "" { prefixPart2 = fmt.Sprintf("[%s: %s]", prefixName, desc) } else { prefixPart2 = fmt.Sprintf("[%s]", prefixName) } width, _, _ := term.GetSize(0) if prefixPart1 != "" { header = fmt.Sprintf("%s %s", prefixPart1, prefixPart2) } else { header = prefixPart2 } headerLength := len(core.Strip(header)) if width > 0 && ts.HeaderChar != "" { repeatCount := max(0, width-headerLength-1) header = fmt.Sprintf("\n%s %s\n\n", header, strings.Repeat(ts.HeaderChar, repeatCount)) } else { header = fmt.Sprintf("\n%s\n\n", header) } fmt.Fprint(stdout, header) } // mani | /projects/mani func getPrefixer(client Client, i, prefixMaxLen int, textStyle dao.Stream, parallel bool) string { if !textStyle.Prefix { return "" } // Project name color var prefixColor color.RGBColor if len(textStyle.PrefixColors) < 1 { prefixColor = dao.StyleFg("") } else { fg := textStyle.PrefixColors[i%len(textStyle.PrefixColors)] prefixColor = dao.StyleFg(fg) } prefix := client.Prefix() prefixLen := len(prefix) // If we don't have a task header or the execution is parallel, then left pad the prefix. if (!textStyle.Header || parallel) && len(prefix) < prefixMaxLen { // Left padding. prefixString := prefix + strings.Repeat(" ", prefixMaxLen-prefixLen) + " | " prefix = prefixColor.Sprint(prefixString) } else { prefixString := prefix + " | " prefix = prefixColor.Sprint(prefixString) } return prefix } func calcMaxPrefixLength(clients []Client) int { var prefixMaxLen = 0 for _, c := range clients { prefix := c.Prefix() if len(prefix) > prefixMaxLen { prefixMaxLen = len(prefix) } } return prefixMaxLen } func printCmd(prefix string, cmd string) { scanner := bufio.NewScanner(strings.NewReader(cmd)) for scanner.Scan() { fmt.Printf("%s%s\n", prefix, scanner.Text()) } } ================================================ FILE: core/exec/unix.go ================================================ //go:build !windows // +build !windows package exec import ( "os" "os/exec" "golang.org/x/sys/unix" ) func ExecTTY(cmd string, envs []string) error { shell := "bash" foundShell, found := os.LookupEnv("SHELL") if found { shell = foundShell } execBin, err := exec.LookPath(shell) if err != nil { return err } userEnv := append(os.Environ(), envs...) err = unix.Exec(execBin, []string{shell, "-c", cmd}, userEnv) if err != nil { return err } return nil } ================================================ FILE: core/exec/windows.go ================================================ //go:build windows // +build windows package exec func ExecTTY(cmd string, envs []string) error { return nil } ================================================ FILE: core/flags.go ================================================ package core // CMD Flags type TUIFlags struct { Theme string Reload bool } type ListFlags struct { Output string Theme string Tree bool } type DescribeFlags struct { Theme string } type SetProjectFlags struct { All bool Cwd bool Target bool } type ProjectFlags struct { All bool Cwd bool Tags []string TagsExpr string Paths []string Projects []string Target string Headers []string Edit bool } type TagFlags struct { Headers []string } type TaskFlags struct { Headers []string Edit bool } type RunFlags struct { Edit bool Parallel bool DryRun bool Silent bool Describe bool Cwd bool TTY bool Theme string Target string Spec string All bool Projects []string Paths []string Tags []string TagsExpr string IgnoreErrors bool IgnoreNonExisting bool OmitEmptyRows bool OmitEmptyColumns bool Output string Forks uint32 } type SetRunFlags struct { TTY bool All bool Cwd bool Parallel bool OmitEmptyColumns bool OmitEmptyRows bool IgnoreErrors bool IgnoreNonExisting bool Forks bool } type SyncFlags struct { IgnoreSyncState bool Parallel bool SyncGitignore bool Status bool SyncRemotes bool RemoveOrphanedWorktrees bool Forks uint32 } type SetSyncFlags struct { Parallel bool SyncGitignore bool SyncRemotes bool RemoveOrphanedWorktrees bool Forks bool } type InitFlags struct { AutoDiscovery bool SyncGitignore bool } ================================================ FILE: core/man.go ================================================ package core import ( _ "embed" "fmt" "os" "path/filepath" ) //go:embed mani.1 var ConfigMan []byte func GenManPages(dir string) error { manPath := filepath.Join(dir, "mani.1") err := os.WriteFile(manPath, ConfigMan, 0644) CheckIfError(err) fmt.Printf("Created %s\n", manPath) return nil } ================================================ FILE: core/man_gen.go ================================================ // This source will generate // - core/mani.1 // - docs/commands.md // // and is not included in the final build. package core import ( "bytes" _ "embed" "fmt" "io" "os" "path/filepath" "slices" "strings" "github.com/spf13/cobra" "github.com/spf13/cobra/doc" "github.com/spf13/pflag" ) //go:embed config.man var ConfigMd []byte type genManHeaders struct { Title string Section string Date string Source string Manual string Version string Desc string } func CreateManPage(desc string, version string, date string, rootCmd *cobra.Command, cmds ...*cobra.Command) error { header := &genManHeaders{ Title: "MANI", Section: "1", Source: "Mani Manual", Manual: "mani", Version: version, Date: date, Desc: desc, } res := genMan(header, rootCmd, cmds...) res = append(res, ConfigMd...) manPath := filepath.Join("./core/", "mani.1") err := os.WriteFile(manPath, res, 0644) if err != nil { return err } fmt.Printf("Created %s\n", manPath) md, err := genDoc(rootCmd, cmds...) if err != nil { return err } mdPath := filepath.Join("./docs/", "commands.md") err = os.WriteFile(mdPath, md, 0644) if err != nil { return err } fmt.Printf("Created %s\n", mdPath) return nil } func manPreamble(buf io.StringWriter, header *genManHeaders, cmd *cobra.Command) { preamble := `.TH "%s" "%s" "%s" "%s" "%s" "%s"` cobra.WriteStringAndCheck(buf, fmt.Sprintf(preamble, header.Title, header.Section, header.Date, header.Version, header.Source, header.Manual)) cobra.WriteStringAndCheck(buf, "\n") cobra.WriteStringAndCheck(buf, ".SH NAME\n") cobra.WriteStringAndCheck(buf, fmt.Sprintf("%s - %s\n", header.Manual, cmd.Short)) cobra.WriteStringAndCheck(buf, "\n") cobra.WriteStringAndCheck(buf, ".SH SYNOPSIS\n") cobra.WriteStringAndCheck(buf, ".B mani [command] [flags]\n") cobra.WriteStringAndCheck(buf, "\n") cobra.WriteStringAndCheck(buf, ".SH DESCRIPTION\n") cobra.WriteStringAndCheck(buf, header.Desc+"\n\n") } func manCommand(buf io.StringWriter, cmd *cobra.Command) { cobra.WriteStringAndCheck(buf, ".TP\n") cobra.WriteStringAndCheck(buf, fmt.Sprintf(`.B %s`, cmd.UseLine())) cobra.WriteStringAndCheck(buf, "\n") cobra.WriteStringAndCheck(buf, fmt.Sprintf("%s\n\n", cmd.Long)) nonInheritedFlags := cmd.NonInheritedFlags() inheritedFlags := cmd.InheritedFlags() if !nonInheritedFlags.HasAvailableFlags() && !inheritedFlags.HasAvailableFlags() { return } cobra.WriteStringAndCheck(buf, "\n.B Available Options:\n") cobra.WriteStringAndCheck(buf, ".RS\n") cobra.WriteStringAndCheck(buf, ".RS\n") if nonInheritedFlags.HasAvailableFlags() { manPrintFlags(buf, nonInheritedFlags) } if inheritedFlags.HasAvailableFlags() && cmd.Name() != "gen" { manPrintFlags(buf, inheritedFlags) cobra.WriteStringAndCheck(buf, "\n") } cobra.WriteStringAndCheck(buf, ".RE\n") cobra.WriteStringAndCheck(buf, ".RE\n") } func manPrintFlags(buf io.StringWriter, flags *pflag.FlagSet) { flags.VisitAll(func(flag *pflag.Flag) { if len(flag.Deprecated) > 0 || flag.Hidden { return } format := "" if len(flag.Shorthand) > 0 && len(flag.ShorthandDeprecated) == 0 { format = fmt.Sprintf("-%s, --%s", flag.Shorthand, flag.Name) } else { format = fmt.Sprintf("--%s", flag.Name) } if len(flag.NoOptDefVal) > 0 { format += "[" } if flag.Value.Type() == "string" { // put quotes on the value format += "=%q" } else { format += "=%s" } if len(flag.NoOptDefVal) > 0 { format += "]" } format = fmt.Sprintf(`\fB%s\fR`, format) format = fmt.Sprintf(format, flag.DefValue) format = fmt.Sprintf(".TP\n%s\n%s\n", format, flag.Usage) cobra.WriteStringAndCheck(buf, format) }) } func genMan(header *genManHeaders, cmd *cobra.Command, cmds ...*cobra.Command) []byte { cmd.InitDefaultHelpCmd() cmd.InitDefaultHelpFlag() buf := new(bytes.Buffer) // PREAMBLE manPreamble(buf, header, cmd) flags := cmd.NonInheritedFlags() // OPTIONS cobra.WriteStringAndCheck(buf, ".SH OPTIONS\n") // FLAGS manPrintFlags(buf, flags) buf.WriteString(".SH\nCOMMANDS\n") // COMMANDS for _, c := range cmds { cbuf := new(bytes.Buffer) if !slices.Contains([]string{"list", "describe"}, c.Name()) { manCommand(cbuf, c) } if len(c.Commands()) > 0 { for _, cc := range c.Commands() { // Don't include help command if cc.Name() != "help" { manCommand(cbuf, cc) } } } buf.Write(cbuf.Bytes()) } return buf.Bytes() } func genDoc(cmd *cobra.Command, cmds ...*cobra.Command) ([]byte, error) { cmd.InitDefaultHelpCmd() cmd.InitDefaultHelpFlag() out := new(bytes.Buffer) err := doc.GenMarkdown(cmd, out) if err != nil { return []byte{}, err } md := out.String() md = strings.Split(md, "### SEE ALSO")[0] md = fmt.Sprintf("%s\n\n%s", "# Commands", md) for _, c := range cmds { if !slices.Contains([]string{"list", "describe"}, c.Name()) { cOut := new(bytes.Buffer) err := doc.GenMarkdown(c, cOut) if err != nil { return []byte{}, err } cMd := cOut.String() cMd = strings.Split(cMd, "### SEE ALSO")[0] md += cMd } if len(c.Commands()) > 0 { for _, cc := range c.Commands() { // Don't include help command if cc.Name() != "help" { ccOut := new(bytes.Buffer) err := doc.GenMarkdown(cc, ccOut) if err != nil { return []byte{}, err } ccMd := ccOut.String() ccMd = strings.Split(ccMd, "### SEE ALSO")[0] md += ccMd } } } } return []byte(md), nil } ================================================ FILE: core/mani.1 ================================================ .TH "MANI" "1" "2025 December 05" "v0.31.2" "Mani Manual" "mani" .SH NAME mani - repositories manager and task runner .SH SYNOPSIS .B mani [command] [flags] .SH DESCRIPTION mani is a CLI tool that helps you manage multiple repositories. It's useful when you are working with microservices, multi-project systems, multiple libraries, or just a collection of repositories and want a central place for pulling all repositories and running commands across them. .SH OPTIONS .TP \fB--color[=true]\fR enable color .TP \fB-c, --config=""\fR specify config .TP \fB-h, --help[=false]\fR help for mani .TP \fB-u, --user-config=""\fR specify user config .SH COMMANDS .TP .B run Run tasks. The tasks are specified in a mani.yaml file along with the projects you can target. .B Available Options: .RS .RS .TP \fB-a, --all[=false]\fR select all projects .TP \fB-k, --cwd[=false]\fR select current working directory .TP \fB--describe[=false]\fR display task information .TP \fB--dry-run[=false]\fR display the task without execution .TP \fB-e, --edit[=false]\fR edit task .TP \fB-f, --forks=4\fR maximum number of concurrent processes .TP \fB--ignore-errors[=false]\fR continue execution despite errors .TP \fB--ignore-non-existing[=false]\fR skip non-existing projects .TP \fB--omit-empty-columns[=false]\fR hide empty columns in table output .TP \fB--omit-empty-rows[=false]\fR hide empty rows in table output .TP \fB-o, --output=""\fR set output format [stream|table|markdown|html] .TP \fB--parallel[=false]\fR execute tasks in parallel across projects .TP \fB-d, --paths=[]\fR select projects by path .TP \fB-p, --projects=[]\fR select projects by name .TP \fB-s, --silent[=false]\fR hide progress output during task execution .TP \fB-J, --spec=""\fR set spec .TP \fB-t, --tags=[]\fR select projects by tag .TP \fB-E, --tags-expr=""\fR select projects by tags expression .TP \fB-T, --target=""\fR select projects by target name .TP \fB--theme=""\fR set theme .TP \fB--tty[=false]\fR replace current process .RE .RE .TP .B exec [flags] Execute arbitrary commands. Use single quotes around your command to prevent file globbing and environment variable expansion from occurring before the command is executed in each directory. .B Available Options: .RS .RS .TP \fB-a, --all[=false]\fR target all projects .TP \fB-k, --cwd[=false]\fR use current working directory .TP \fB--dry-run[=false]\fR print commands without executing them .TP \fB-f, --forks=4\fR maximum number of concurrent processes .TP \fB--ignore-errors[=false]\fR ignore errors .TP \fB--ignore-non-existing[=false]\fR ignore non-existing projects .TP \fB--omit-empty-columns[=false]\fR omit empty columns in table output .TP \fB--omit-empty-rows[=false]\fR omit empty rows in table output .TP \fB-o, --output=""\fR set output format [stream|table|markdown|html] .TP \fB--parallel[=false]\fR run tasks in parallel across projects .TP \fB-d, --paths=[]\fR select projects by path .TP \fB-p, --projects=[]\fR select projects by name .TP \fB-s, --silent[=false]\fR hide progress when running tasks .TP \fB-J, --spec=""\fR set spec .TP \fB-t, --tags=[]\fR select projects by tag .TP \fB-E, --tags-expr=""\fR select projects by tags expression .TP \fB-T, --target=""\fR target projects by target name .TP \fB--theme=""\fR set theme .TP \fB--tty[=false]\fR replace current process .RE .RE .TP .B init [flags] Initialize a mani repository. Creates a new mani repository by generating a mani.yaml configuration file and a .gitignore file in the current directory. .B Available Options: .RS .RS .TP \fB--auto-discovery[=true]\fR automatically discover and add Git repositories to mani.yaml .TP \fB-g, --sync-gitignore[=true]\fR synchronize .gitignore file .RE .RE .TP .B sync [flags] Clone repositories and update .gitignore file. For repositories requiring authentication, disable parallel cloning to enter credentials for each repository individually. .B Available Options: .RS .RS .TP \fB-f, --forks=4\fR maximum number of concurrent processes .TP \fB--ignore-sync-state[=false]\fR sync project even if the project's sync field is set to false .TP \fB-p, --parallel[=false]\fR clone projects in parallel .TP \fB-d, --paths=[]\fR clone projects by path .TP \fB-s, --status[=false]\fR display status only .TP \fB-g, --sync-gitignore[=true]\fR sync gitignore .TP \fB-r, --sync-remotes[=false]\fR update git remote state .TP \fB-t, --tags=[]\fR clone projects by tags .TP \fB-E, --tags-expr=""\fR clone projects by tag expression .RE .RE .TP .B edit Open up mani config file in $EDITOR. .TP .B edit project [project] Edit mani project in $EDITOR. .TP .B edit task [task] Edit mani task in $EDITOR. .TP .B list projects [projects] [flags] List projects. .B Available Options: .RS .RS .TP \fB-a, --all[=true]\fR select all projects .TP \fB-k, --cwd[=false]\fR select current working directory .TP \fB--headers=[project,tag,description]\fR specify columns to display [project, path, relpath, description, url, tag] .TP \fB-d, --paths=[]\fR select projects by paths .TP \fB-t, --tags=[]\fR select projects by tags .TP \fB-E, --tags-expr=""\fR select projects by tags expression .TP \fB-T, --target=""\fR select projects by target name .TP \fB--tree[=false]\fR display output in tree format .TP \fB-o, --output="table"\fR set output format [table|markdown|html] .TP \fB--theme="default"\fR set theme .RE .RE .TP .B list tags [tags] [flags] List tags. .B Available Options: .RS .RS .TP \fB--headers=[tag,project]\fR specify columns to display [project, tag] .TP \fB-o, --output="table"\fR set output format [table|markdown|html] .TP \fB--theme="default"\fR set theme .RE .RE .TP .B list tasks [tasks] [flags] List tasks. .B Available Options: .RS .RS .TP \fB--headers=[task,description]\fR specify columns to display [task, description, target, spec] .TP \fB-o, --output="table"\fR set output format [table|markdown|html] .TP \fB--theme="default"\fR set theme .RE .RE .TP .B describe projects [projects] [flags] Describe projects. .B Available Options: .RS .RS .TP \fB-a, --all[=true]\fR select all projects .TP \fB-k, --cwd[=false]\fR select current working directory .TP \fB-e, --edit[=false]\fR edit project .TP \fB-d, --paths=[]\fR filter projects by paths .TP \fB-t, --tags=[]\fR filter projects by tags .TP \fB-E, --tags-expr=""\fR target projects by tags expression .TP \fB-T, --target=""\fR target projects by target name .TP \fB--theme="default"\fR set theme .RE .RE .TP .B describe tasks [tasks] [flags] Describe tasks. .B Available Options: .RS .RS .TP \fB-e, --edit[=false]\fR edit task .TP \fB--theme="default"\fR set theme .RE .RE .TP .B tui [flags] Run TUI .B Available Options: .RS .RS .TP \fB-r, --reload-on-change[=false]\fR reload mani on config change .TP \fB--theme="default"\fR set theme .RE .RE .TP .B check Validate config. .TP .B gen .B Available Options: .RS .RS .TP \fB-d, --dir="./"\fR directory to save manpage to .RE .RE .SH CONFIG The mani.yaml config is based on the following concepts: .RS 2 .IP "\(bu" 2 \fBprojects\fR are directories, which may be git repositories, in which case they have an URL attribute .PD 0 .IP "\(bu" 2 \fBtasks\fR are shell commands that you write and then run for selected \fBprojects\fR .IP "\(bu" 2 \fBspecs\fR are configs that alter \fBtask\fR execution and output .PD 0 .IP "\(bu" 2 \fBtargets\fR are configs that provide shorthand filtering of \fBprojects\fR when executing tasks .PD 0 .IP "\(bu" 2 \fBenv\fR are environment variables that can be defined globally, per project and per task .PD 0 .RE \fBSpecs\fR, \fBtargets\fR and \fBthemes\fR use a \fBdefault\fR object by default that the user can override to modify execution of mani commands. Check the files and environment section to see how the config file is loaded. Below is a config file detailing all of the available options and their defaults. .RS 4 # Import projects/tasks/env/specs/themes/targets from other configs import: - ./some-dir/mani.yaml # Shell used for commands # If you use any other program than bash, zsh, sh, node, and python # then you have to provide the command flag if you want the command-line string evaluted # For instance: bash -c shell: bash # If set to true, mani will override the URL of any existing remote # and remove remotes not found in the config sync_remotes: false # Determines whether the .gitignore should be updated when syncing projects sync_gitignore: true # When running the TUI, specifies whether it should reload when the mani config is changed reload_tui_on_change: false # List of Projects projects: # Project name [required] pinto: # Determines if the project should be synchronized during 'mani sync' sync: true # Project path relative to the config file # Defaults to project name if not specified path: frontend/pinto # Repository URL url: git@github.com:alajmo/pinto # Project description desc: A vim theme editor # Custom clone command # Defaults to "git clone URL PATH" clone: git clone git@github.com:alajmo/pinto --branch main # Branch to use as primary HEAD when cloning # Defaults to repository's primary HEAD branch: # When true, clones only the specified branch or primary HEAD single_branch: false # Project tags tags: [dev] # Remote repositories # Key is the remote name, value is the URL remotes: foo: https://github.com/bar # Project-specific environment variables env: # Simple string value branch: main # Shell command substitution date: $(date -u +"%Y-%m-%dT%H:%M:%S%Z") # List of Specs specs: default: # Output format for task results # Options: stream, table, html, markdown output: stream # Enable parallel task execution parallel: false # Maximum number of concurrent tasks when running in parallel forks: 4 # When true, continues execution if a command fails in a multi-command task ignore_errors: false # When true, skips project entries in the config that don't exist # on the filesystem without throwing an error ignore_non_existing: false # Hide projects with no command output omit_empty_rows: false # Hide columns with no data omit_empty_columns: false # Clear screen before task execution (TUI only) clear_output: true # List of targets targets: default: # Select all projects all: false # Select project in current working directory cwd: false # Select projects by name projects: [] # Select projects by path paths: [] # Select projects by tag tags: [] # Select projects by tag expression tags_expr: "" # Environment variables available to all tasks env: # Simple string value AUTHOR: "alajmo" # Shell command substitution DATE: $(date -u +"%Y-%m-%dT%H:%M:%S%Z") # List of tasks tasks: # Command name [required] simple-2: echo "hello world" # Command name [required] simple-1: cmd: | echo "hello world" desc: simple command 1 # Command name [required] advanced-command: # Task description desc: complex task # Task theme theme: default # Shell interpreter shell: bash # Task-specific environment variables env: # Static value branch: main # Dynamic shell command output num_lines: $(ls -1 | wc -l) # Can reference predefined spec: # spec: custom_spec # or define inline: spec: output: table parallel: true forks: 4 ignore_errors: false ignore_non_existing: true omit_empty_rows: true omit_empty_columns: true # Can reference predefined target: # target: custom_target # or define inline: target: all: true cwd: false projects: [pinto] paths: [frontend] tags: [dev] tags_expr: (prod || dev) && !test # Single multi-line command cmd: | echo complex echo command # Multiple commands commands: # Node.js command example - name: node-example shell: node cmd: console.log("hello world from node.js"); # Reference to another task - task: simple-1 # List of themes # Styling Options: # Fg (foreground color): Empty string (""), hex color, or named color from W3C standard # Bg (background color): Empty string (""), hex color, or named color from W3C standard # Format: Empty string (""), "lower", "title", "upper" # Attribute: Empty string (""), "bold", "italic", "underline" # Alignment: Empty string (""), "left", "center", "right" themes: # Theme name [required] default: # Stream Output Configuration stream: # Include project name prefix for each line prefix: true # Colors to alternate between for each project prefix prefix_colors: ["#d787ff", "#00af5f", "#d75f5f", "#5f87d7", "#00af87", "#5f00ff"] # Add a header before each project header: true # String value that appears before the project name in the header header_prefix: "TASK" # Fill remaining spaces with a character after the prefix header_char: "*" # Table Output Configuration table: # Table style # Available options: ascii, light, bold, double, rounded style: ascii # Border options for table output border: around: false # Border around the table columns: true # Vertical border between columns header: true # Horizontal border between headers and rows rows: false # Horizontal border between rows header: fg: "#d787ff" attr: bold format: "" title_column: fg: "#5f87d7" attr: bold format: "" # Tree View Configuration tree: # Tree style # Available options: ascii, light, bold, double, rounded, bullet-square, bullet-circle, bullet-star style: ascii # Block Display Configuration block: key: fg: "#5f87d7" separator: fg: "#5f87d7" value: fg: value_true: fg: "#00af5f" value_false: fg: "#d75f5f" # TUI Configuration tui: default: fg: bg: attr: border: fg: border_focus: fg: "#d787ff" title: fg: bg: attr: align: center title_active: fg: "#000000" bg: "#d787ff" attr: align: center button: fg: bg: attr: format: button_active: fg: "#080808" bg: "#d787ff" attr: format: table_header: fg: "#d787ff" bg: attr: bold align: left format: item: fg: bg: attr: item_focused: fg: "#ffffff" bg: "#262626" attr: item_selected: fg: "#5f87d7" bg: attr: item_dir: fg: "#d787ff" bg: attr: item_ref: fg: "#d787ff" bg: attr: search_label: fg: "#d7d75f" bg: attr: bold search_text: fg: bg: attr: filter_label: fg: "#d7d75f" bg: attr: bold filter_text: fg: bg: attr: shortcut_label: fg: "#00af5f" bg: attr: shortcut_text: fg: bg: attr: .RE .SH EXAMPLES .TP Initialize mani .B samir@hal-9000 ~ $ mani init .nf Initialized mani repository in /tmp - Created mani.yaml - Created .gitignore Following projects were added to mani.yaml Project | Path ----------+------------ test | . pinto | dev/pinto .fi .TP Clone projects .B samir@hal-9000 ~ $ mani sync --parallel --forks 8 .nf pinto | Cloning into '/tmp/dev/pinto'... Project | Synced ----------+-------- test | ✓ pinto | ✓ .fi .TP List all projects .B samir@hal-9000 ~ $ mani list projects .nf Project --------- test pinto .fi .TP List all projects with output set to tree .nf .B samir@hal-9000 ~ $ mani list projects --tree ── dev └─ pinto .fi .nf .TP List all tags .B samir@hal-9000 ~ $ mani list tags .nf Tag | Project -----+--------- dev | pinto .fi .TP List all tasks .nf .B samir@hal-9000 ~ $ mani list tasks Task | Description ------------------+------------------ simple-1 | simple command 1 simple-2 | advanced-command | complex task .fi .TP Describe a task .nf .B samir@hal-9000 ~ $ mani describe tasks advanced-command Name: advanced-command Description: complex task Theme: default Target: All: true Cwd: false Projects: pinto Paths: frontend Tags: dev TagsExpr: "" Spec: Output: table Parallel: true Forks: 4 IgnoreErrors: false IgnoreNonExisting: false OmitEmptyRows: false OmitEmptyColumns: false Env: branch: dev num_lines: 2 Cmd: echo advanced echo command Commands: - simple-1 - simple-2 - cmd .fi .TP Run a task for all projects with tag 'dev' .nf .B samir@hal-9000 ~ $ mani run simple-1 --tags dev Project | Simple-1 ---------+------------- pinto | hello world .fi .TP Run a task for all projects matching tags expression 'dev && !prod' .nf .B samir@hal-9000 ~ $ mani run simple-1 --tags-expr '(dev && !prod)' Project | Simple-1 ---------+------------- pinto | hello world .fi .TP Run ad-hoc command for all projects .nf .B samir@hal-9000 ~ $ mani exec 'echo 123' --all Project | Output ---------+-------- archive | 123 pinto | 123 .fi .SH FILTERING PROJECTS Projects can be filtered when managing projects (sync, list, describe) or running tasks. Filters can be specified through CLI flags or target configurations. The filtering is inclusive, meaning projects must satisfy all specified filters to be included in the results. .PP Available options: .RS 2 .IP "\(bu" 2 cwd: include only the project under the current working directory, ignoring all other filters .IP "\(bu" 2 all: include all projects .IP "\(bu" 2 projects: Filter by project names .IP "\(bu" 2 paths: Filter by project paths .IP "\(bu" 2 tags: Filter by project tags .IP "\(bu" 2 tags_expr: Filter using tag logic expressions .IP "\(bu" 2 target: Filter using target .RE .PP For \fBmani sync/list/describe\fR: .RS 2 .IP "\(bu" 2 No filters: Targets all projects .IP "\(bu" 2 Multiple filters: Select intersection of projects/paths/tags/tags_expr/target filters .RE For \fBmani run/exec\fR: .RS 2 .IP "1." 4 Runtime flags (highest priority) .IP "2." 4 Target flag configuration (\fB--target\fR) .IP "3." 4 Task's default target data (lowest priority) .RE The default target is named `default` and can be overridden by defining a target named `default` in the config. This only applies for sub-commands `run` and `exec`. .SH TAGS EXPRESSION Tag expressions allow filtering projects using boolean operations on their tags. The expression is evaluated for each project's tags to determine if the project should be included. .PP Operators (in precedence order): .RS 2 .IP "\(bu" 2 (): Parentheses for grouping .PD 0 .IP "\(bu" 2 !: NOT operator (logical negation) .PD 0 .IP "\(bu" 2 &&: AND operator (logical conjunction) .PD 0 .IP "\(bu" 2 ||: OR operator (logical disjunction) .RE .PP For example, the expression: \fB(main && (dev || prod)) && !test\fR .PP requires the projects to pass these conditions: .RS 2 .IP "\(bu" 2 Must have "main" tag .PD 0 .IP "\(bu" 2 Must have either "dev" OR "prod" tag .IP "\(bu" 2 Must NOT have "test" tag .PD 0 .RE .SH FILES When running a command, .B mani will check the current directory and all parent directories for the following files: mani.yaml, mani.yml, .mani.yaml, .mani.yml. Additionally, it will import (if found) a config file from: .RS 2 .IP "\(bu" 2 Linux: \fB$XDG_CONFIG_HOME/mani/config.yaml\fR or \fB$HOME/.config/mani/config.yaml\fR if \fB$XDG_CONFIG_HOME\fR is not set. .IP "\(bu" 2 Darwin: \fB$HOME/Library/Application Support/mani/config.yaml\fR .IP "\(bu" 2 Windows: \fB%AppData%\mani\fR .RE Both the config and user config can be specified via flags or environments variables. .SH ENVIRONMENT .TP .B MANI_CONFIG Override config file path .TP .B MANI_USER_CONFIG Override user config file path .TP .B NO_COLOR If this env variable is set (regardless of value) then all colors will be disabled .SH BUGS See GitHub Issues: .UR https://github.com/alajmo/mani/issues .ME . .SH AUTHOR .B mani was written by Samir Alajmovic .MT alajmovic.samir@gmail.com .ME . For updates and more information go to .UR https://\:www.manicli.com manicli.com .UE . ================================================ FILE: core/prefixer.go ================================================ // Source: https://github.com/goware/prefixer // Author: goware package core import ( "bufio" "io" ) // Prefixer implements io.Reader and io.WriterTo. It reads // data from the underlying reader and prepends every line // with a given string. type Prefixer struct { reader *bufio.Reader prefix []byte unread []byte eof bool } // New creates a new instance of Prefixer. func NewPrefixer(r io.Reader, prefix string) *Prefixer { return &Prefixer{ reader: bufio.NewReader(r), prefix: []byte(prefix), } } // Read implements io.Reader. It reads data into p from the // underlying reader and prepends every line with a prefix. // It does not block if no data is available yet. // It returns the number of bytes read into p. func (r *Prefixer) Read(p []byte) (n int, err error) { for { // Write unread data from previous read. if len(r.unread) > 0 { m := copy(p[n:], r.unread) n += m r.unread = r.unread[m:] if len(r.unread) > 0 { return n, nil } } // The underlying Reader already returned EOF, do not read again. if r.eof { return n, io.EOF } // Read new line, including delim. r.unread, err = r.reader.ReadBytes('\n') if err == io.EOF { r.eof = true } // No new data, do not block. if len(r.unread) == 0 { return n, err } // Some new data, prepend prefix. // TODO: We could write the prefix to r.unread buffer just once // and re-use it instead of prepending every time. r.unread = append(r.prefix, r.unread...) if err != nil { if err == io.EOF && len(r.unread) > 0 { // The underlying Reader already returned EOF, but we still // have some unread data to send, thus clear the error. return n, nil } return n, err } } } func (r *Prefixer) WriteTo(w io.Writer) (n int64, err error) { for { // Write unread data from previous read. if len(r.unread) > 0 { m, err := w.Write(r.unread) n += int64(m) if err != nil { return n, err } r.unread = r.unread[m:] if len(r.unread) > 0 { return n, nil } } // The underlying Reader already returned EOF, do not read again. if r.eof { return n, io.EOF } // Read new line, including delim. r.unread, err = r.reader.ReadBytes('\n') if err == io.EOF { r.eof = true } // No new data, do not block. if len(r.unread) == 0 { return n, err } // Some new data, prepend prefix. // TODO: We could write the prefix to r.unread buffer just once // and re-use it instead of prepending every time. r.unread = append(r.prefix, r.unread...) if err != nil { if err == io.EOF && len(r.unread) > 0 { // The underlying Reader already returned EOF, but we still // have some unread data to send, thus clear the error. return n, nil } return n, err } } } ================================================ FILE: core/prefixer_benchmark_test.go ================================================ package core import ( "bytes" "fmt" "io" "strings" "testing" ) // Prefixer_Read: Read() with varying line counts and sizes func BenchmarkPrefixer_Read(b *testing.B) { lineCounts := []int{10, 100, 1000} lineSizes := []int{50, 200, 500} for _, lineCount := range lineCounts { for _, lineSize := range lineSizes { name := fmt.Sprintf("lines_%d_size_%d", lineCount, lineSize) b.Run(name, func(b *testing.B) { // Create input with specified number of lines var input strings.Builder line := strings.Repeat("x", lineSize) + "\n" for i := 0; i < lineCount; i++ { input.WriteString(line) } inputStr := input.String() b.ResetTimer() for i := 0; i < b.N; i++ { reader := strings.NewReader(inputStr) prefixer := NewPrefixer(reader, "[project-name] ") buf := make([]byte, 4096) for { _, err := prefixer.Read(buf) if err == io.EOF { break } } } }, ) } } } // Prefixer_WriteTo: WriteTo() with varying line counts func BenchmarkPrefixer_WriteTo(b *testing.B) { lineCounts := []int{10, 100, 1000} for _, lineCount := range lineCounts { name := fmt.Sprintf("lines_%d", lineCount) b.Run(name, func(b *testing.B) { // Create input with specified number of lines var input strings.Builder line := strings.Repeat("x", 80) + "\n" for i := 0; i < lineCount; i++ { input.WriteString(line) } inputStr := input.String() b.ResetTimer() for i := 0; i < b.N; i++ { reader := strings.NewReader(inputStr) prefixer := NewPrefixer(reader, "[project-name] ") var buf bytes.Buffer _, _ = prefixer.WriteTo(&buf) } }) } } // Prefixer_PrefixLen: Impact of prefix length on performance func BenchmarkPrefixer_PrefixLen(b *testing.B) { prefixLengths := []int{10, 50, 100} for _, prefixLen := range prefixLengths { name := fmt.Sprintf("prefix_%d", prefixLen) b.Run(name, func(b *testing.B) { // Create input with 100 lines var input strings.Builder line := strings.Repeat("x", 80) + "\n" for i := 0; i < 100; i++ { input.WriteString(line) } inputStr := input.String() prefix := strings.Repeat("P", prefixLen) + " " b.ResetTimer() for i := 0; i < b.N; i++ { reader := strings.NewReader(inputStr) prefixer := NewPrefixer(reader, prefix) var buf bytes.Buffer _, _ = prefixer.WriteTo(&buf) } }) } } // Prefixer_Allocs: Memory allocation count (optimization target) func BenchmarkPrefixer_Allocs(b *testing.B) { // Create input with 100 lines var input strings.Builder line := strings.Repeat("x", 80) + "\n" for i := 0; i < 100; i++ { input.WriteString(line) } inputStr := input.String() b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { reader := strings.NewReader(inputStr) prefixer := NewPrefixer(reader, "[project-name] ") var buf bytes.Buffer _, _ = prefixer.WriteTo(&buf) } } ================================================ FILE: core/print/lib.go ================================================ package print import ( "bufio" "strings" "unicode/utf8" ) func GetMaxTextWidth(text string) int { scanner := bufio.NewScanner(strings.NewReader(text)) maxWidth := 0 for scanner.Scan() { lineWidth := utf8.RuneCountInString(scanner.Text()) if lineWidth > maxWidth { maxWidth = lineWidth } } return maxWidth } func GetTextDimensions(text string) (int, int) { // TODO: Seems it also counts color codes, so need to skip that scanner := bufio.NewScanner(strings.NewReader(text)) maxWidth := 0 height := 0 for scanner.Scan() { height++ lineWidth := utf8.RuneCountInString(scanner.Text()) if lineWidth > maxWidth { maxWidth = lineWidth } } return maxWidth, height } ================================================ FILE: core/print/print_block.go ================================================ package print import ( "bufio" "fmt" "strconv" "strings" "github.com/alajmo/mani/core/dao" ) var FORMATTER Formatter var COLORIZE bool var BLOCK dao.Block func PrintProjectBlocks(projects []dao.Project, colorize bool, block dao.Block, f Formatter) string { if len(projects) == 0 { return "" } FORMATTER = f COLORIZE = colorize BLOCK = block output := "" output += fmt.Sprintln() for i, project := range projects { output += printKeyValue(false, "", "name", ":", project.Name, *block.Key, *block.Value) output += printKeyValue(false, "", "sync", ":", strconv.FormatBool(project.IsSync()), *block.Key, trueOrFalse(project.IsSync())) if project.Desc != "" { output += printKeyValue(false, "", "description", ":", project.Desc, *block.Key, *block.Value) } if project.RelPath != project.Name { output += printKeyValue(false, "", "path", ":", project.RelPath, *block.Key, *block.Value) } output += printKeyValue(false, "", "url", ":", project.URL, *block.Key, *block.Value) if len(project.RemoteList) > 0 { output += printKeyValue(false, "", "remotes", ":", "", *block.Key, *block.Value) for _, remote := range project.RemoteList { output += printKeyValue(true, "", remote.Name, ":", remote.URL, *block.Key, *block.Value) } } if len(project.WorktreeList) > 0 { output += printKeyValue(false, "", "worktrees", ":", "", *block.Key, *block.Value) for _, wt := range project.WorktreeList { output += printKeyValue(true, "- ", "path", ":", wt.Path, *block.Key, *block.Value) output += printKeyValue(true, " ", "branch", ":", wt.Branch, *block.Key, *block.Value) } } if project.Branch != "" { output += printKeyValue(false, "", "branch", ":", project.Branch, *block.Key, *block.Value) } output += printKeyValue(false, "", "single_branch", ":", strconv.FormatBool(project.IsSingleBranch()), *block.Key, trueOrFalse(project.IsSingleBranch())) if len(project.Tags) > 0 { output += printKeyValue(false, "", "tags", ":", project.GetValue("tag", 0), *block.Key, *block.Value) } if len(project.EnvList) > 0 { output += printEnv(project.EnvList, block) } if i < len(projects)-1 { output += "\n--\n\n" } } output += fmt.Sprintln() return output } func PrintTaskBlock(tasks []dao.Task, colorize bool, block dao.Block, f Formatter) string { if len(tasks) == 0 { return "" } FORMATTER = f COLORIZE = colorize BLOCK = block output := "" output += fmt.Sprintln() for i, task := range tasks { output += printKeyValue(false, "", "name", ":", task.Name, *block.Key, *block.Value) output += printKeyValue(false, "", "description", ":", task.Desc, *block.Key, *block.Value) output += printKeyValue(false, "", "theme", ":", task.ThemeData.Name, *block.Key, *block.Value) output += printKeyValue(false, "", "target", ":", "", *block.Key, *block.Value) output += printKeyValue(true, "", "all", ":", strconv.FormatBool(task.TargetData.All), *block.Key, trueOrFalse(task.TargetData.All)) output += printKeyValue(true, "", "cwd", ":", strconv.FormatBool(task.TargetData.Cwd), *block.Key, trueOrFalse(task.TargetData.Cwd)) output += printKeyValue(true, "", "projects", ":", strings.Join(task.TargetData.Projects, ", "), *block.Key, *block.Value) output += printKeyValue(true, "", "paths", ":", strings.Join(task.TargetData.Paths, ", "), *block.Key, *block.Value) output += printKeyValue(true, "", "tags", ":", strings.Join(task.TargetData.Tags, ", "), *block.Key, *block.Value) output += printKeyValue(true, "", "tags_expr", ":", task.TargetData.TagsExpr, *block.Key, *block.Value) output += printKeyValue(false, "", "spec", ":", "", *block.Key, *block.Value) output += printKeyValue(true, "", "output", ":", task.SpecData.Output, *block.Key, *block.Value) output += printKeyValue(true, "", "parallel", ":", strconv.FormatBool(task.SpecData.Parallel), *block.Key, trueOrFalse(task.SpecData.Parallel)) output += printKeyValue(true, "", "ignore_errors", ":", strconv.FormatBool(task.SpecData.IgnoreErrors), *block.Key, trueOrFalse(task.SpecData.IgnoreErrors)) output += printKeyValue(true, "", "omit_empty_rows", ":", strconv.FormatBool(task.SpecData.OmitEmptyRows), *block.Key, trueOrFalse(task.SpecData.OmitEmptyRows)) output += printKeyValue(true, "", "omit_empty_columns", ":", strconv.FormatBool(task.SpecData.OmitEmptyColumns), *block.Key, trueOrFalse(task.SpecData.OmitEmptyColumns)) if len(task.EnvList) > 0 { output += printEnv(task.EnvList, block) } if task.Cmd != "" { output += printKeyValue(false, "", "cmd", ":", "", *block.Key, *block.Value) output += printCmd(task.Cmd) } if len(task.Commands) > 0 { output += printKeyValue(false, "", "commands", ":", "", *block.Key, *block.Value) for _, subCommand := range task.Commands { if subCommand.Name != "" { if subCommand.Desc != "" { output += printKeyValue(true, "- ", subCommand.Name, ":", subCommand.Desc, *block.Key, *block.Value) } else { output += printKeyValue(true, "- ", subCommand.Name, "", "", *block.Key, *block.Value) } } else { output += printKeyValue(true, "- ", "cmd", "", "", *block.Value, *block.Value) } } } if i < len(tasks)-1 { output += "\n--\n\n" } } output += fmt.Sprintln() return output } type Formatter interface { Format(prefix string, key string, value string, separator string, keyColor *dao.ColorOptions, valueColor *dao.ColorOptions) string } func printKeyValue( padding bool, prefix string, key string, separator string, value string, keyStyle dao.ColorOptions, valueStyle dao.ColorOptions, ) string { if !COLORIZE { str := fmt.Sprintf("%s%s%s %s\n", prefix, key, separator, value) if padding { return fmt.Sprintf("%4s%s", " ", str) } return str } str := FORMATTER.Format(prefix, key, value, separator, &keyStyle, &valueStyle) str += "\n" if padding { str = fmt.Sprintf("%4s%s", " ", str) } return str } func printCmd(cmd string) string { output := "" scanner := bufio.NewScanner(strings.NewReader(cmd)) for scanner.Scan() { output += fmt.Sprintf("%4s%s\n", " ", scanner.Text()) } return output } func printEnv(env []string, block dao.Block) string { output := "" output += printKeyValue(false, "", "env", ":", "", *block.Key, *block.Value) for _, env := range env { parts := strings.SplitN(strings.TrimSuffix(env, "\n"), "=", 2) output += printKeyValue(true, "", parts[0], ":", parts[1], *block.Key, *block.Value) } return output } func trueOrFalse(value bool) dao.ColorOptions { if value { return *BLOCK.ValueTrue } return *BLOCK.ValueFalse } type TviewFormatter struct{} type GookitFormatter struct{} func (t TviewFormatter) Format( prefix string, key string, value string, separator string, keyColor *dao.ColorOptions, valueColor *dao.ColorOptions, ) string { sepStr := fmt.Sprintf("[%s:-:%s]%s", *BLOCK.Separator.Fg, *BLOCK.Separator.Attr, separator) return fmt.Sprintf( "[%s:-:%s]%s%s[-::-]%s[-:-:-] [%s:-:%s]%s", *keyColor.Fg, *keyColor.Attr, prefix, key, sepStr, *valueColor.Fg, *valueColor.Attr, value, ) } func (g GookitFormatter) Format( prefix string, key string, value string, separator string, keyColor *dao.ColorOptions, valueColor *dao.ColorOptions, ) string { prefixStr := dao.StyleString(prefix, *keyColor, true) keyStr := dao.StyleString(key, *keyColor, true) sepStr := dao.StyleString(separator, *BLOCK.Separator, true) valueStr := dao.StyleString(value, *valueColor, true) return fmt.Sprintf("%s%s%s %s", prefixStr, keyStr, sepStr, valueStr) } ================================================ FILE: core/print/print_table.go ================================================ package print import ( "io" "github.com/alajmo/mani/core/dao" "github.com/jedib0t/go-pretty/v6/table" ) type Items interface { GetValue(string, int) string } type PrintTableOptions struct { Output string Theme dao.Theme Tree bool Color bool AutoWrap bool OmitEmptyRows bool OmitEmptyColumns bool } func PrintTable[T Items]( data []T, options PrintTableOptions, defaultHeaders []string, taskHeaders []string, writer io.Writer, ) { // Colors not supported for markdown and html switch options.Output { case "markdown": options.Color = false case "html": options.Color = false } t := CreateTable(options, defaultHeaders, taskHeaders, data, writer) theme := options.Theme // Headers var headers table.Row for _, h := range defaultHeaders { headers = append(headers, dao.StyleString(h, *theme.Table.Header, options.Color)) } for _, h := range taskHeaders { headers = append(headers, dao.StyleString(h, *theme.Table.Header, options.Color)) } t.AppendHeader(headers) // Rows headerNames := append(defaultHeaders, taskHeaders...) for _, item := range data { row := table.Row{} for i, h := range headerNames { value := item.GetValue(h, i) if i == 0 { value = dao.StyleString(value, *theme.Table.TitleColumn, options.Color) } row = append(row, value) } if options.OmitEmptyRows { empty := true for _, v := range row[1:] { if v != "" { empty = false break } } if empty { continue } } t.AppendRow(row) } RenderTable(t, options.Output) } ================================================ FILE: core/print/print_tree.go ================================================ package print import ( "fmt" "strings" "github.com/jedib0t/go-pretty/v6/list" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" ) func PrintTree(config *dao.Config, theme dao.Theme, listFlags *core.ListFlags, tree []dao.TreeNode) { // Style var treeStyle list.Style switch theme.Tree.Style { case "light": treeStyle = list.StyleConnectedLight case "bullet-flower": treeStyle = list.StyleBulletFlower case "bullet-square": treeStyle = list.StyleBulletSquare case "bullet-star": treeStyle = list.StyleBulletStar case "bullet-triangle": treeStyle = list.StyleBulletTriangle case "bold": treeStyle = list.StyleConnectedBold case "double": treeStyle = list.StyleConnectedDouble case "rounded": treeStyle = list.StyleConnectedRounded case "markdown": treeStyle = list.StyleMarkdown default: treeStyle = list.StyleDefault } // Print l := list.NewWriter() l.SetStyle(treeStyle) printTreeNodes(l, tree, 0) switch listFlags.Output { case "markdown": printTree(l.RenderMarkdown()) case "html": printTree(l.RenderHTML()) default: printTree(l.Render()) } } func printTreeNodes(l list.Writer, tree []dao.TreeNode, depth int) { for _, n := range tree { for range depth { l.Indent() } l.AppendItem(n.Path) printTreeNodes(l, n.Children, depth+1) for range depth { l.UnIndent() } } } func printTree(content string) { for line := range strings.SplitSeq(content, "\n") { fmt.Printf("%s\n", line) } fmt.Println() } ================================================ FILE: core/print/table.go ================================================ package print import ( "io" "github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/text" "golang.org/x/term" "github.com/alajmo/mani/core/dao" ) func CreateTable[T Items]( options PrintTableOptions, defaultHeaders []string, taskHeaders []string, data []T, writer io.Writer, ) table.Writer { t := table.NewWriter() theme := options.Theme t.SetOutputMirror(writer) t.SetStyle(FormatTable(theme)) if options.OmitEmptyColumns { t.SuppressEmptyColumns() } canWrap, maxColumnWidths := calcColumnWidths(defaultHeaders, taskHeaders, data) headers := []table.ColumnConfig{} for i := range defaultHeaders { headerStyle := table.ColumnConfig{ Number: i + 1, } if options.AutoWrap && canWrap { headerStyle.WidthMaxEnforcer = text.WrapText headerStyle.WidthMax = maxColumnWidths[i] } headers = append(headers, headerStyle) } for i := range taskHeaders { offset := len(defaultHeaders) + i headerStyle := table.ColumnConfig{ Number: len(defaultHeaders) + 1 + i, } if options.AutoWrap && canWrap { headerStyle.WidthMaxEnforcer = text.WrapText headerStyle.WidthMax = maxColumnWidths[offset] } headers = append(headers, headerStyle) } t.SetColumnConfigs(headers) return t } func FormatTable(theme dao.Theme) table.Style { return table.Style{ Name: theme.Name, Box: theme.Table.Box, Options: table.Options{ DrawBorder: *theme.Table.Border.Around, SeparateColumns: *theme.Table.Border.Columns, SeparateHeader: *theme.Table.Border.Header, SeparateRows: *theme.Table.Border.Rows, }, } } func RenderTable(t table.Writer, output string) { switch output { case "markdown": t.RenderMarkdown() case "html": t.RenderHTML() default: t.Render() } } func calcColumnWidths[T Items]( defaultHeaders []string, taskHeaders []string, data []T, ) (bool, []int) { headers := append(defaultHeaders, taskHeaders...) columnWidths := make([]int, len(headers)) headerPaddingsSum := 3*len(headers) + 1 // Initialize column widths based on headers for i, header := range headers { columnWidths[i] = GetMaxTextWidth(header) } // Update column widths based on rows for _, row := range data { for j, column := range headers { value := row.GetValue(column, j) columnWidth := GetMaxTextWidth(value) if columnWidths[j] < columnWidth { columnWidths[j] = columnWidth } } } // Calculate total width and check against terminal width columnSum := headerPaddingsSum for _, width := range columnWidths { columnSum += width } terminalWidth, _, _ := term.GetSize(0) if columnSum < terminalWidth { return false, columnWidths } maxColumnWidth := (terminalWidth - headerPaddingsSum) / (len(columnWidths)) var affectedColumns []int for i := range columnWidths { if columnWidths[i] > maxColumnWidth { columnWidths[i] = maxColumnWidth affectedColumns = append(affectedColumns, i) } } columnSum = headerPaddingsSum for _, width := range columnWidths { columnSum += width } addToEach := (terminalWidth - columnSum) / len(affectedColumns) for _, col := range affectedColumns { columnWidths[col] += addToEach } return true, columnWidths } ================================================ FILE: core/sizedwaitgroup.go ================================================ // Source: https://github.com/remeh/sizedwaitgroup/blob/master/sizedwaitgroup.go // Author: Rémy Mathieu package core import ( "context" "math" "sync" ) // SizedWaitGroup has the same role and close to the // same API as the Golang sync.WaitGroup but adds a limit of // the amount of goroutines started concurrently. type SizedWaitGroup struct { Size uint32 current chan struct{} wg sync.WaitGroup } // New creates a SizedWaitGroup. // The limit parameter is the maximum amount of // goroutines which can be started concurrently. func NewSizedWaitGroup(limit uint32) SizedWaitGroup { var size uint32 size = math.MaxUint32 // 2^31 - 1 if limit > 0 { size = limit } return SizedWaitGroup{ Size: size, current: make(chan struct{}, size), wg: sync.WaitGroup{}, } } // Add increments the internal WaitGroup counter. // It can be blocking if the limit of spawned goroutines // has been reached. It will stop blocking when Done is // been called. // // See sync.WaitGroup documentation for more information. func (s *SizedWaitGroup) Add() { _ = s.AddWithContext(context.Background()) } // AddWithContext increments the internal WaitGroup counter. // It can be blocking if the limit of spawned goroutines // has been reached. It will stop blocking when Done is // been called, or when the context is canceled. Returns nil on // success or an error if the context is canceled before the lock // is acquired. // // See sync.WaitGroup documentation for more information. func (s *SizedWaitGroup) AddWithContext(ctx context.Context) error { select { case <-ctx.Done(): return ctx.Err() case s.current <- struct{}{}: break } s.wg.Add(1) return nil } // Done decrements the SizedWaitGroup counter. // See sync.WaitGroup documentation for more information. func (s *SizedWaitGroup) Done() { <-s.current s.wg.Done() } // Wait blocks until the SizedWaitGroup counter is zero. // See sync.WaitGroup documentation for more information. func (s *SizedWaitGroup) Wait() { s.wg.Wait() } ================================================ FILE: core/tui/components/tui_button.go ================================================ package components import ( "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/tui/misc" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) func CreateButton(label string) *tview.Button { label = dao.StyleFormat(label, misc.STYLE_BUTTON.FormatStr) button := tview.NewButton(label) SetInactiveButtonStyle(button) return button } func SetActiveButtonStyle(button *tview.Button) { label := button.GetLabel() button.SetLabel(dao.StyleFormat(label, misc.STYLE_BUTTON_ACTIVE.FormatStr)) button. SetStyle(tcell.StyleDefault. Foreground(misc.STYLE_BUTTON_ACTIVE.Fg). Background(misc.STYLE_BUTTON_ACTIVE.Bg). Attributes(misc.STYLE_BUTTON_ACTIVE.Attr)). SetActivatedStyle(tcell.StyleDefault. Foreground(misc.STYLE_BUTTON_ACTIVE.Fg). Background(misc.STYLE_BUTTON_ACTIVE.Bg). Attributes(misc.STYLE_BUTTON_ACTIVE.Attr)) } func SetInactiveButtonStyle(button *tview.Button) { label := button.GetLabel() button.SetLabel(dao.StyleFormat(label, misc.STYLE_BUTTON.FormatStr)) button. SetStyle(tcell.StyleDefault. Foreground(misc.STYLE_BUTTON.Fg). Background(misc.STYLE_BUTTON.Bg). Attributes(misc.STYLE_BUTTON.Attr)). SetActivatedStyle(tcell.StyleDefault. Foreground(misc.STYLE_BUTTON.Fg). Background(misc.STYLE_BUTTON.Bg). Attributes(misc.STYLE_BUTTON.Attr)) } ================================================ FILE: core/tui/components/tui_checkbox.go ================================================ package components import ( "github.com/alajmo/mani/core/tui/misc" "github.com/rivo/tview" ) func Checkbox(label string, checked *bool, onFocus func(), onBlur func()) *tview.Checkbox { checkbox := tview.NewCheckbox().SetLabel(" " + label + " ") checkbox.SetChecked(*checked) checkbox.SetCheckedStyle(misc.STYLE_ITEM_SELECTED.Style) checkbox.SetUncheckedStyle(misc.STYLE_ITEM.Style) checkbox.SetFieldTextColor(misc.STYLE_ITEM_FOCUSED.Bg) checkbox.SetFieldBackgroundColor(misc.STYLE_ITEM.Bg) checkbox.SetCheckedString("") if *checked { checkbox.SetLabelStyle(misc.STYLE_ITEM_SELECTED.Style) } else { checkbox.SetLabelStyle(misc.STYLE_ITEM.Style) } // Callbacks checkbox.SetFocusFunc(func() { if *checked { checkbox.SetLabelColor(misc.STYLE_ITEM_SELECTED.Fg) } else { checkbox.SetLabelColor(misc.STYLE_ITEM_FOCUSED.Fg) } checkbox.SetBackgroundColor(misc.STYLE_ITEM_FOCUSED.Bg) onFocus() }) checkbox.SetBlurFunc(func() { if *checked { checkbox.SetLabelColor(misc.STYLE_ITEM_SELECTED.Fg) } else { checkbox.SetLabelColor(misc.STYLE_ITEM.Fg) } checkbox.SetBackgroundColor(misc.STYLE_ITEM.Bg) onBlur() }) checkbox.SetChangedFunc(func(isChecked bool) { if isChecked { checkbox.SetLabelStyle(misc.STYLE_ITEM_SELECTED.Style) } else { checkbox.SetLabelStyle(misc.STYLE_ITEM.Style) } *checked = !*checked }) return checkbox } ================================================ FILE: core/tui/components/tui_filter.go ================================================ package components import ( "github.com/rivo/tview" "github.com/alajmo/mani/core/tui/misc" ) func CreateFilter() *tview.InputField { filter := tview.NewInputField(). SetLabel(""). SetLabelStyle(misc.STYLE_FILTER_LABEL.Style). SetFieldStyle(misc.STYLE_FILTER_TEXT.Style) return filter } func ShowFilter(filter *tview.InputField, text string) { filter.SetLabel(misc.Colorize("Filter:", *misc.TUITheme.FilterLabel)) filter.SetText(text) misc.App.SetFocus(filter) } func CloseFilter(filter *tview.InputField) { filter.SetLabel("") filter.SetText("") } func InitFilter(filter *tview.InputField, text string) { if text != "" { filter.SetLabel(" Filter: ") filter.SetText(text) } else { filter.SetLabel("") filter.SetText("") } } ================================================ FILE: core/tui/components/tui_list.go ================================================ package components import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/alajmo/mani/core/tui/misc" ) type TList struct { Root *tview.Flex List *tview.List Filter *tview.InputField Title string FilterValue *string IsItemSelected func(item string) bool ToggleSelectItem func(i int, itemName string) SelectAll func() UnselectAll func() FilterItems func() } func (l *TList) Create() { // Init list := tview.NewList(). ShowSecondaryText(false). SetHighlightFullLine(true). SetSelectedStyle(misc.STYLE_ITEM_FOCUSED.Style). SetMainTextColor(misc.STYLE_ITEM.Fg) filter := CreateFilter() root := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(list, 0, 1, true). AddItem(filter, 1, 0, false) root.SetTitleColor(misc.STYLE_TITLE.Fg) root.SetTitleAlign(misc.STYLE_TITLE.Align). SetBorder(true). SetBorderPadding(1, 0, 1, 1) l.Filter = filter l.Root = root l.List = list if l.Title != "" { misc.SetActive(l.Root.Box, l.Title, false) } l.IsItemSelected = func(item string) bool { return false } l.ToggleSelectItem = func(i int, itemName string) {} l.SelectAll = func() {} l.UnselectAll = func() {} l.FilterItems = func() {} // Filter l.Filter.SetChangedFunc(func(_ string) { l.applyFilter() l.FilterItems() }) l.Filter.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { currentFocus := misc.App.GetFocus() if currentFocus == filter { switch event.Key() { case tcell.KeyEscape: l.ClearFilter() misc.App.SetFocus(list) return nil case tcell.KeyEnter: l.applyFilter() misc.App.SetFocus(list) } return event } return event }) // Input l.List.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { // Need to check filter in-case list is empty switch event.Key() { case tcell.KeyRune: switch event.Rune() { case 'f': // Filter ShowFilter(filter, *l.FilterValue) return nil case 'F': // Remove filter CloseFilter(filter) *l.FilterValue = "" return nil } } numItems := l.List.GetItemCount() if numItems == 0 { return nil } currentItemIndex := l.List.GetCurrentItem() _, secondaryText := l.List.GetItemText(currentItemIndex) switch event.Key() { case tcell.KeyEnter: l.ToggleSelectItem(currentItemIndex, secondaryText) return nil case tcell.KeyCtrlD: current := list.GetCurrentItem() _, _, _, height := list.GetInnerRect() newPos := min(current+height/2, list.GetItemCount()-1) list.SetCurrentItem(newPos) return nil case tcell.KeyCtrlU: current := list.GetCurrentItem() _, _, _, height := list.GetInnerRect() newPos := max(current-height/2, 0) list.SetCurrentItem(newPos) return nil case tcell.KeyCtrlF: current := list.GetCurrentItem() _, _, _, height := list.GetInnerRect() newPos := min(current+height, list.GetItemCount()-1) list.SetCurrentItem(newPos) return nil case tcell.KeyCtrlB: current := list.GetCurrentItem() _, _, _, height := list.GetInnerRect() newPos := max(current-height, 0) list.SetCurrentItem(newPos) return nil case tcell.KeyRune: switch event.Rune() { case 'g': // top l.List.SetCurrentItem(0) return nil case 'G': // bottom l.List.SetCurrentItem(numItems - 1) return nil case 'j': // down nextItem := currentItemIndex + 1 if nextItem < numItems { l.List.SetCurrentItem(nextItem) } return nil case 'k': // up nextItem := currentItemIndex - 1 if nextItem >= 0 { l.List.SetCurrentItem(nextItem) } return nil case 'a': // Select all l.SelectAll() return nil case 'c': // Unselect all l.UnselectAll() return nil case ' ': // Select (Space) l.ToggleSelectItem(currentItemIndex, secondaryText) return nil } } return event }) // Events l.List.SetFocusFunc(func() { misc.PreviousPane = l.List misc.SetActive(l.Root.Box, l.Title, true) }) l.List.SetBlurFunc(func() { misc.PreviousPane = l.List misc.SetActive(l.Root.Box, l.Title, false) }) } func (l *TList) Update(items []string) { l.List.Clear() for _, name := range items { l.List.AddItem(l.getItemText(name), name, 0, nil) } } func (l *TList) SetItemSelect(i int, item string) { if l.IsItemSelected(item) { value := misc.Colorize(item, *misc.TUITheme.ItemSelected) l.List.SetItemText(i, value, item) } else { value := misc.Colorize(item, *misc.TUITheme.Item) l.List.SetItemText(i, value, item) } } func (l *TList) ClearFilter() { CloseFilter(l.Filter) *l.FilterValue = "" } func (l *TList) applyFilter() { *l.FilterValue = l.Filter.GetText() } func (l *TList) getItemText(item string) string { if l.IsItemSelected(item) { value := misc.Colorize(item, *misc.TUITheme.ItemSelected) return value } return misc.PadString(item) } ================================================ FILE: core/tui/components/tui_modal.go ================================================ package components import ( "strings" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "golang.org/x/term" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/tui/misc" ) // OpenModal Used for when a custom tview Flex is passed to a modal. func OpenModal(pageTitle string, title string, contentPane *tview.Flex, width int, height int) { termWidth, termHeight, _ := term.GetSize(0) if width > termWidth { width = termWidth - 5 } if height > termHeight { height = termHeight - 5 } formattedTitle := misc.ColorizeTitle(dao.StyleFormat(title, misc.STYLE_TITLE_ACTIVE.FormatStr), *misc.TUITheme.TitleActive) contentPane.SetTitle(formattedTitle) background := tview.NewBox() containerFlex := tview.NewFlex(). AddItem(contentPane, 0, 1, true) containerFlex.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { background.SetRect(x, y, width, height) background.Draw(screen) contentPane.SetRect(x, y, width, height) contentPane.Draw(screen) return x, y, width, height }) modal := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(nil, 0, 1, false). AddItem( tview.NewFlex().SetDirection(tview.FlexColumn). AddItem(nil, 0, 1, false). AddItem(containerFlex, width, 1, true). AddItem(nil, 0, 1, false), height, 1, true, ). AddItem(nil, 0, 1, false) modal.SetFullScreen(true) EmptySearch() misc.Pages.AddPage(pageTitle, modal, false, true) misc.App.SetFocus(containerFlex) } // OpenTextModal Used for when text is passed to a modal. func OpenTextModal(pageTitle string, textColor string, textNoColor string, title string) { width, height := misc.GetTexztModalSize(textNoColor) textColor = strings.TrimSpace(textColor) // Text contentPane := tview.NewTextView(). SetText(textColor). SetTextAlign(tview.AlignLeft). SetDynamicColors(true) // Border formattedTitle := misc.ColorizeTitle(dao.StyleFormat(title, misc.STYLE_TITLE_ACTIVE.FormatStr), *misc.TUITheme.TitleActive) contentPane.SetBorder(true). SetTitle(formattedTitle). SetTitleAlign(misc.STYLE_TITLE.Align). SetBorderColor(misc.STYLE_BORDER_FOCUS.Fg). SetBorderPadding(1, 1, 2, 2) // Colors contentPane.SetBackgroundColor(misc.STYLE_DEFAULT.Bg) contentPane.SetTextColor(misc.STYLE_DEFAULT.Fg) // Container modal := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(nil, 0, 1, false). AddItem( tview.NewFlex().SetDirection(tview.FlexColumn). AddItem(nil, 0, 1, false). AddItem(contentPane, width, 1, true). AddItem(nil, 0, 1, false), height, 1, true, ). AddItem(nil, 0, 1, false) modal.SetFullScreen(true).SetBackgroundColor(misc.STYLE_DEFAULT.Fg) EmptySearch() misc.Pages.AddPage(pageTitle, modal, false, true) misc.App.SetFocus(contentPane) } func CloseModal() { // Need to store before removing, because otherwise // the first pane gets focused and so misc.PreviousPage // doesn't work as intended. previousPane := misc.PreviousPane frontPageName, _ := misc.Pages.GetFrontPage() misc.Pages.RemovePage(frontPageName) misc.App.SetFocus(previousPane) } func IsModalOpen() bool { frontPageName, _ := misc.Pages.GetFrontPage() return strings.Contains(frontPageName, "-modal") } ================================================ FILE: core/tui/components/tui_output.go ================================================ package components import ( "github.com/alajmo/mani/core/tui/misc" "github.com/rivo/tview" ) func CreateOutputView(title string) (*tview.TextView, *misc.ThreadSafeWriter) { streamView := CreateText(title) ansiWriter := misc.NewThreadSafeWriter(streamView) return streamView, ansiWriter } ================================================ FILE: core/tui/components/tui_search.go ================================================ package components import ( "strings" "github.com/rivo/tview" "github.com/alajmo/mani/core/tui/misc" ) func CreateSearch() *tview.InputField { search := tview.NewInputField(). SetLabel(""). SetLabelStyle(misc.STYLE_SEARCH_LABEL.Style). SetFieldStyle(misc.STYLE_SEARCH_TEXT.Style) return search } func ShowSearch() { misc.Search.SetLabel(misc.Colorize("Search:", *misc.TUITheme.SearchLabel)) misc.Search.SetText("") misc.App.SetFocus(misc.Search) } func EmptySearch() { misc.Search.SetLabel("") misc.Search.SetText("") } func SearchInTable(table *tview.Table, query string, lastFoundRow, lastFoundCol *int, direction int) { query = strings.ToLower(query) rowCount := table.GetRowCount() colCount := table.GetColumnCount() startRow := *lastFoundRow if startRow == -1 { startRow = 0 } else { startRow += direction } searchRow := startRow for range rowCount { if searchRow < 0 { searchRow = rowCount - 1 } else if searchRow >= rowCount { searchRow = 0 } for col := range colCount { if cell := table.GetCell(searchRow, col); cell != nil { if strings.Contains(strings.ToLower(strings.TrimSpace(cell.Text)), query) { table.Select(searchRow, col) *lastFoundRow, *lastFoundCol = searchRow, col return } } } searchRow += direction } *lastFoundRow, *lastFoundCol = -1, -1 } func SearchInTree(tree *TTree, query string, lastFoundIndex *int, direction int) { query = strings.ToLower(query) itemCount := len(tree.List) startIndex := *lastFoundIndex if startIndex == -1 { startIndex = 0 } else { startIndex += direction } searchIndex := startIndex for range itemCount { if searchIndex < 0 { searchIndex = itemCount - 1 } else if searchIndex >= itemCount { searchIndex = 0 } name := strings.ToLower(tree.List[searchIndex].DisplayName) if strings.Contains(name, query) { tree.Tree.SetCurrentNode(tree.List[searchIndex].TreeNode) *lastFoundIndex = searchIndex return } searchIndex += direction } *lastFoundIndex = -1 } func SearchInList(list *tview.List, query string, lastFoundIndex *int, direction int) { query = strings.ToLower(query) itemCount := list.GetItemCount() startIndex := *lastFoundIndex if startIndex == -1 { startIndex = 0 } else { startIndex += direction } searchIndex := startIndex for range itemCount { if searchIndex < 0 { searchIndex = itemCount - 1 } else if searchIndex >= itemCount { searchIndex = 0 } mainText, secondaryText := list.GetItemText(searchIndex) if strings.Contains(strings.ToLower(mainText), query) || strings.Contains(strings.ToLower(secondaryText), query) { list.SetCurrentItem(searchIndex) *lastFoundIndex = searchIndex return } searchIndex += direction } *lastFoundIndex = -1 } ================================================ FILE: core/tui/components/tui_table.go ================================================ package components import ( "strings" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/tui/misc" ) type TTable struct { Root *tview.Flex Table *tview.Table Filter *tview.InputField Title string FilterValue *string ShowHeaders bool ToggleEnabled bool IsRowSelected func(name string) bool ToggleSelectRow func(name string) SelectAll func() UnselectAll func() FilterRows func() DescribeRow func(name string) EditRow func(name string) } func (t *TTable) Create() { // Init table := tview.NewTable() table.SetFixed(1, 1) // Fixed header + name column table.Select(1, 0) // Select first row table.SetEvaluateAllRows(true) // Avoid resizing of headers when scrolling table.SetSelectable(true, false) // Only rows can be selected table.SetBackgroundColor(misc.STYLE_ITEM.Bg) filter := CreateFilter() root := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(table, 0, 1, true). AddItem(filter, 1, 0, false) root.SetTitleColor(misc.STYLE_TITLE.Fg) root.SetTitleAlign(misc.STYLE_TITLE.Align). SetBorder(true). SetBorderPadding(1, 0, 1, 1) t.Table = table t.Filter = filter t.Root = root if t.Title != "" { misc.SetActive(t.Root.Box, t.Title, false) } // Methods t.IsRowSelected = func(name string) bool { return false } t.ToggleSelectRow = func(name string) {} t.SelectAll = func() {} t.UnselectAll = func() {} t.FilterRows = func() {} t.DescribeRow = func(_ string) {} t.EditRow = func(projectName string) {} // Filter t.Filter.SetChangedFunc(func(_ string) { t.applyFilter() t.FilterRows() }) t.Filter.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { currentFocus := misc.App.GetFocus() if currentFocus == filter { switch event.Key() { case tcell.KeyEscape: t.ClearFilter() t.FilterRows() misc.App.SetFocus(table) return nil case tcell.KeyEnter: t.applyFilter() t.FilterRows() misc.App.SetFocus(table) } return event } return event }) // Input t.Table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEnter: if t.ToggleEnabled { row, _ := table.GetSelection() name := strings.TrimSpace(table.GetCell(row, 0).Text) t.ToggleSelectRow(name) } return nil case tcell.KeyCtrlD: row, _ := table.GetSelection() _, _, _, height := table.GetInnerRect() newRow := min(row+height/2, table.GetRowCount()-1) table.Select(newRow, 0) return nil case tcell.KeyCtrlU: row, _ := table.GetSelection() _, _, _, height := table.GetInnerRect() newRow := max(row-height/2, 1) table.Select(newRow, 0) return nil case tcell.KeyCtrlF: row, _ := table.GetSelection() _, _, _, height := table.GetInnerRect() newRow := min(row+height, table.GetRowCount()-1) if newRow == 0 { newRow = 1 // Skip header } table.Select(newRow, 0) return nil case tcell.KeyCtrlB: row, _ := table.GetSelection() _, _, _, height := table.GetInnerRect() newRow := max(row-height, 1) table.Select(newRow, 0) return nil case tcell.KeyRune: switch event.Rune() { case ' ': // Toggle item (space) if t.ToggleEnabled { row, _ := table.GetSelection() name := strings.TrimSpace(table.GetCell(row, 0).Text) t.ToggleSelectRow(name) } return nil case 'a': // Select all if t.ToggleEnabled { t.SelectAll() } return nil case 'c': // Unselect all if t.ToggleEnabled { t.UnselectAll() } return nil case 'f': // Filter rows ShowFilter(filter, *t.FilterValue) return nil case 'F': // Remove filter CloseFilter(filter) *t.FilterValue = "" return nil case 'o': // Edit in editor row, _ := t.Table.GetSelection() name := strings.TrimSpace(t.Table.GetCell(row, 0).Text) t.EditRow(name) return nil case 'd': // Open description modal row, _ := t.Table.GetSelection() name := strings.TrimSpace(t.Table.GetCell(row, 0).Text) t.DescribeRow(name) return nil } } return event }) // Events t.Table.SetSelectionChangedFunc(func(row, column int) { t.UpdateRowStyle() }) t.Table.SetFocusFunc(func() { InitFilter(t.Filter, *t.FilterValue) misc.PreviousPane = t.Table misc.SetActive(t.Root.Box, t.Title, true) }) t.Table.SetBlurFunc(func() { misc.PreviousPane = t.Table misc.SetActive(t.Root.Box, t.Title, false) }) } func (t *TTable) CreateTableHeader(header string) *tview.TableCell { // TODO: format return tview.NewTableCell(dao.StyleFormat(header, misc.STYLE_TABLE_HEADER.FormatStr)). SetTextColor(misc.STYLE_TABLE_HEADER.Fg). SetAttributes(misc.STYLE_TABLE_HEADER.Attr). SetAlign(misc.STYLE_TABLE_HEADER.Align). SetSelectable(false) } func (t *TTable) Update(headers []string, rows [][]string) { t.Table.Clear() // Add headers and updates style for col, header := range headers { if t.ShowHeaders { t.Table.SetCell(0, col, t.CreateTableHeader(misc.PadString(header))) } else { t.Table.SetCell(0, col, t.CreateTableHeader("")) } } // Add rows and updates style for i := range rows { for j := range rows[i] { name := misc.PadString(rows[i][j]) cell := tview.NewTableCell(name) t.Table.SetCell(i+1, j, cell) t.SetRowSelect(i + 1) } } } func (t *TTable) UpdateRowStyle() { for row := 1; row < t.Table.GetRowCount(); row++ { t.SetRowSelect(row) } } func (t *TTable) ToggleSelectCurrentRow(name string) { index := -1 for row := 1; row < t.Table.GetRowCount(); row++ { cell := strings.TrimSpace(t.Table.GetCell(row, 0).Text) if cell == name { index = row break } } t.SetRowSelect(index) } func (t *TTable) SetRowSelect(row int) { // Ignore header row focusedRow, _ := t.Table.GetSelection() if focusedRow == 0 { return } name := strings.TrimSpace(t.Table.GetCell(row, 0).Text) isSelected := t.IsRowSelected(name) isFocused := row == focusedRow style := tcell.StyleDefault if isFocused && isSelected { style = style. Foreground(misc.STYLE_ITEM_SELECTED.Fg). Background(misc.STYLE_ITEM_FOCUSED.Bg). Attributes(misc.STYLE_ITEM_SELECTED.Attr) } else if isFocused { style = style. Foreground(misc.STYLE_ITEM_FOCUSED.Fg). Background(misc.STYLE_ITEM_FOCUSED.Bg). Attributes(misc.STYLE_ITEM_FOCUSED.Attr) } else if isSelected { style = style. Foreground(misc.STYLE_ITEM_SELECTED.Fg). Background(misc.STYLE_ITEM_SELECTED.Bg). Attributes(misc.STYLE_ITEM_SELECTED.Attr) } else { style = style. Foreground(misc.STYLE_ITEM.Fg). Background(misc.STYLE_ITEM.Bg). Attributes(misc.STYLE_ITEM.Attr) } // Apply styles to all cells in the row for col := range t.Table.GetColumnCount() { cell := t.Table.GetCell(row, col) cell.SetStyle(style) cell.SetSelectedStyle(style) } } func (t *TTable) ClearFilter() { CloseFilter(t.Filter) *t.FilterValue = "" } func (t *TTable) applyFilter() { *t.FilterValue = t.Filter.GetText() } ================================================ FILE: core/tui/components/tui_text.go ================================================ package components import ( "github.com/alajmo/mani/core/tui/misc" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) func CreateText(title string) *tview.TextView { textview := tview.NewTextView() textview.SetBorder(true) textview.SetBorderPadding(0, 0, 2, 1) textview.SetDynamicColors(true) textview.SetWrap(false) textTitle := title if textTitle != "" { textTitle = misc.Colorize(title, *misc.TUITheme.Title) textview.SetTitle(textTitle) } textview.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { _, _, _, height := textview.GetInnerRect() row, _ := textview.GetScrollOffset() switch { case event.Key() == tcell.KeyCtrlD || event.Rune() == 'd': textview.ScrollTo(row+height/2, 0) return nil case event.Key() == tcell.KeyCtrlU || event.Rune() == 'u': textview.ScrollTo(row-height/2, 0) return nil case event.Key() == tcell.KeyCtrlF || event.Rune() == 'f': textview.ScrollTo(row+height, 0) return nil case event.Key() == tcell.KeyCtrlB || event.Rune() == 'b': textview.ScrollTo(row-height, 0) return nil } return event }) // Callbacks textview.SetFocusFunc(func() { misc.PreviousPane = textview misc.SetActive(textview.Box, title, true) }) textview.SetBlurFunc(func() { misc.PreviousPane = textview misc.SetActive(textview.Box, title, false) }) return textview } ================================================ FILE: core/tui/components/tui_textarea.go ================================================ package components import ( "github.com/alajmo/mani/core/tui/misc" "github.com/rivo/tview" ) func CreateTextArea(title string) *tview.TextArea { textarea := tview.NewTextArea() textarea.SetBorder(true) textarea.SetWrap(true) textarea.SetTitle(title) textarea.SetTitleAlign(misc.STYLE_TITLE.Align) textarea.SetTitleColor(misc.STYLE_DEFAULT.Fg) textarea.SetBackgroundColor(misc.STYLE_DEFAULT.Bg) textarea.SetBorderPadding(0, 0, 1, 1) // Callbacks textarea.SetFocusFunc(func() { misc.PreviousPane = textarea misc.SetActive(textarea.Box, title, true) }) textarea.SetBlurFunc(func() { misc.PreviousPane = textarea misc.SetActive(textarea.Box, title, false) }) return textarea } ================================================ FILE: core/tui/components/tui_toggle_text.go ================================================ package components import ( "github.com/alajmo/mani/core/tui/misc" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) type TToggleText struct { Value *string Option1 string Option2 string Label1 string Label2 string Data1 string Data2 string TextView *tview.TextView } func (t *TToggleText) Create() { textview := tview.NewTextView() textview.SetTitle("") if *t.Value == t.Option1 { textview.SetText(t.Label1) } else { textview.SetText(t.Label2) } textview.SetSize(1, 18) textview.SetBorder(false) textview.SetBorderPadding(0, 0, 0, 0) textview.SetBackgroundColor(misc.STYLE_ITEM.Bg) toggleOutput := func() { if *t.Value == t.Option1 { *t.Value = t.Option2 textview.SetText(t.Label2) } else { *t.Value = t.Option1 textview.SetText(t.Label1) } } textview.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEnter: toggleOutput() return nil case tcell.KeyRune: switch event.Rune() { case ' ': // space toggleOutput() return nil } } return event }) textview.SetFocusFunc(func() { textview.SetTextColor(misc.STYLE_ITEM_FOCUSED.Fg) textview.SetBackgroundColor(misc.STYLE_ITEM_FOCUSED.Bg) }) textview.SetBlurFunc(func() { textview.SetTextColor(misc.STYLE_ITEM.Fg) textview.SetBackgroundColor(misc.STYLE_ITEM.Bg) }) t.TextView = textview } ================================================ FILE: core/tui/components/tui_tree.go ================================================ package components import ( "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/tui/misc" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) type TTree struct { Tree *tview.TreeView Root *tview.Flex RootNode *tview.TreeNode Filter *tview.InputField List []*TNode Title string RootTitle string FilterValue *string SelectEnabled bool IsNodeSelected func(name string) bool ToggleSelectNode func(name string) SelectAll func() UnselectAll func() FilterNodes func() DescribeNode func(name string) EditNode func(name string) } type TNode struct { ID string // The reference DisplayName string // What is shown Type string TreeNode *tview.TreeNode Children *[]TNode } func (t *TTree) Create() { title := misc.Colorize(t.RootTitle, *misc.TUITheme.Item) rootNode := tview.NewTreeNode(title) rootNode.SetColor(misc.STYLE_DEFAULT.Fg) rootNode.SetSelectable(false) t.IsNodeSelected = func(name string) bool { return false } t.ToggleSelectNode = func(name string) {} t.SelectAll = func() {} t.UnselectAll = func() {} t.FilterNodes = func() {} t.DescribeNode = func(name string) {} t.EditNode = func(name string) {} tree := tview.NewTreeView(). SetRoot(rootNode). SetCurrentNode(rootNode) tree.SetGraphics(true) filter := CreateFilter() root := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(tree, 0, 1, true). AddItem(filter, 1, 0, false) root.SetTitleAlign(misc.STYLE_TITLE.Align). SetBorder(true). SetBorderPadding(0, 0, 1, 1) t.Root = root t.Filter = filter t.RootNode = rootNode t.Tree = tree if t.Title != "" { title := misc.Colorize(t.Title, *misc.TUITheme.Title) t.Root.SetTitle(title) } // Methods t.IsNodeSelected = func(name string) bool { return false } // Filter t.Filter.SetChangedFunc(func(_ string) { t.applyFilter() t.FilterNodes() }) t.Filter.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { currentFocus := misc.App.GetFocus() if currentFocus == filter { switch event.Key() { case tcell.KeyEscape: t.ClearFilter() t.FilterNodes() misc.App.SetFocus(tree) return nil case tcell.KeyEnter: t.applyFilter() t.FilterNodes() misc.App.SetFocus(tree) } return event } return event }) // Input t.Tree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyEnter: if t.SelectEnabled { node := t.Tree.GetCurrentNode() name := node.GetReference().(string) t.ToggleSelectNode(name) } case tcell.KeyCtrlD: current := t.Tree.GetCurrentNode() _, _, _, height := t.Tree.GetInnerRect() visibleNodes := t.getVisibleNodes() currentIndex := t.findNodeIndex(visibleNodes, current) newIndex := min(currentIndex+height/2, len(visibleNodes)-1) if newIndex > 0 && newIndex < len(visibleNodes) { t.Tree.SetCurrentNode(visibleNodes[newIndex]) } return nil case tcell.KeyCtrlU: current := t.Tree.GetCurrentNode() _, _, _, height := t.Tree.GetInnerRect() visibleNodes := t.getVisibleNodes() currentIndex := t.findNodeIndex(visibleNodes, current) newIndex := max(currentIndex-height/2, 0) if newIndex >= 0 && newIndex < len(visibleNodes) { t.Tree.SetCurrentNode(visibleNodes[newIndex]) } return nil case tcell.KeyCtrlF: current := t.Tree.GetCurrentNode() _, _, _, height := t.Tree.GetInnerRect() visibleNodes := t.getVisibleNodes() currentIndex := t.findNodeIndex(visibleNodes, current) newIndex := min(currentIndex+height, len(visibleNodes)-1) if newIndex > 0 && newIndex < len(visibleNodes) { t.Tree.SetCurrentNode(visibleNodes[newIndex]) } return nil case tcell.KeyCtrlB: current := t.Tree.GetCurrentNode() _, _, _, height := t.Tree.GetInnerRect() visibleNodes := t.getVisibleNodes() currentIndex := t.findNodeIndex(visibleNodes, current) newIndex := max(currentIndex-height, 0) if newIndex >= 0 && newIndex < len(visibleNodes) { t.Tree.SetCurrentNode(visibleNodes[newIndex]) } return nil case tcell.KeyRune: switch event.Rune() { case ' ': // Toggle item (space) if t.SelectEnabled { node := t.Tree.GetCurrentNode() name := node.GetReference().(string) t.ToggleSelectNode(name) } return nil case 'a': // Select all if t.SelectEnabled { t.SelectAll() } return nil case 'c': // Unselect all all if t.SelectEnabled { t.UnselectAll() } return nil case 'f': // Filter rows ShowFilter(filter, *t.FilterValue) return nil case 'F': // Remove filter CloseFilter(filter) *t.FilterValue = "" return nil case 'o': // Edit in editor item := tree.GetCurrentNode() name := item.GetReference().(string) t.EditNode(name) return nil case 'd': // Open description modal item := tree.GetCurrentNode() name := item.GetReference().(string) t.DescribeNode(name) return nil case 'g': // Top tree.SetCurrentNode(rootNode) misc.App.QueueEvent(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)) return nil case 'G': // Bottom children := rootNode.GetChildren() last := children[len(children)-1] name := last.GetReference().(string) if name == "" { children = last.GetChildren() last = children[len(children)-1] } tree.SetCurrentNode(last) misc.App.QueueEvent(tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone)) return nil } } return event }) // Events var previousNode *tview.TreeNode var previousColor tcell.Color tree.SetChangedFunc(func(node *tview.TreeNode) { if previousNode != nil { previousNode.SetColor(previousColor) } if node != nil { previousColor = node.GetColor() previousNode = node node.SetColor(misc.STYLE_ITEM_FOCUSED.Bg) } }) t.Tree.SetFocusFunc(func() { InitFilter(t.Filter, *t.FilterValue) misc.PreviousPane = t.Tree misc.PreviousModel = t misc.SetActive(t.Root.Box, t.Title, true) }) t.Tree.SetBlurFunc(func() { misc.PreviousPane = t.Tree misc.PreviousModel = t misc.SetActive(t.Root.Box, t.Title, false) }) } func (t *TTree) UpdateProjects(paths []dao.TNode) { t.RootNode.ClearChildren() var itree []dao.TreeNode for i := range paths { itree = dao.AddToTree(itree, paths[i]) } t.List = []*TNode{} for i := range itree { t.BuildProjectTree(t.RootNode, itree[i]) } } func (t *TTree) UpdateProjectsStyle() { for _, node := range t.List { t.setNodeSelect(node) } } func (t *TTree) BuildProjectTree(node *tview.TreeNode, tnode dao.TreeNode) { // Project if len(tnode.Children) == 0 { pathName := misc.Colorize(tnode.Path, *misc.TUITheme.Item) childTreeNode := tview.NewTreeNode(pathName). SetReference(tnode.ProjectName). SetSelectable(true) node.AddChild(childTreeNode) childListNode := &TNode{ ID: tnode.ProjectName, DisplayName: tnode.Path, Type: "project", TreeNode: childTreeNode, Children: &[]TNode{}, } t.List = append(t.List, childListNode) return } // Directory pathName := misc.Colorize(tnode.Path, *misc.TUITheme.ItemDir) parentTreeNode := tview.NewTreeNode(pathName). SetReference(""). SetSelectable(false) node.AddChild(parentTreeNode) parentListNode := &TNode{ ID: tnode.ProjectName, DisplayName: tnode.Path, TreeNode: parentTreeNode, Type: "directory", } t.List = append(t.List, parentListNode) for i := range tnode.Children { t.BuildProjectTree(parentTreeNode, tnode.Children[i]) } } func (t *TTree) UpdateTasks(nodes []TNode) { t.RootNode.ClearChildren() t.List = []*TNode{} for _, parentNode := range nodes { // Parent displayName := misc.Colorize(parentNode.DisplayName, *misc.TUITheme.Item) parentTreeNode := tview.NewTreeNode(displayName). SetReference(parentNode.ID). SetSelectable(true) t.RootNode.AddChild(parentTreeNode) parentListNode := &TNode{ DisplayName: parentNode.DisplayName, ID: parentNode.DisplayName, Type: parentNode.Type, TreeNode: parentTreeNode, Children: &[]TNode{}, } t.List = append(t.List, parentListNode) // Children for _, childNode := range *parentNode.Children { displayName := misc.Colorize(parentNode.DisplayName, *misc.TUITheme.Item) childTreeNode := tview. NewTreeNode(displayName). SetSelectable(false) parentTreeNode.AddChild(childTreeNode) listChildNode := &TNode{ DisplayName: childNode.DisplayName, Type: childNode.Type, TreeNode: childTreeNode, Children: &[]TNode{}, } *parentListNode.Children = append(*parentListNode.Children, *listChildNode) } } } func (t *TTree) UpdateTasksStyle() { for _, node := range t.List { if t.IsNodeSelected(node.DisplayName) { displayName := misc.Colorize(node.DisplayName, *misc.TUITheme.ItemSelected) node.TreeNode.SetText(displayName) for _, child := range *node.Children { displayName := misc.Colorize(child.DisplayName, *misc.TUITheme.ItemSelected) child.TreeNode.SetText(displayName) } } else { displayName := misc.Colorize(node.DisplayName, *misc.TUITheme.Item) node.TreeNode.SetText(displayName) for _, child := range *node.Children { if child.Type == "task-ref" { displayName := misc.Colorize(child.DisplayName, *misc.TUITheme.ItemRef) child.TreeNode.SetText(displayName) } else { displayName := misc.Colorize(child.DisplayName, *misc.TUITheme.Item) child.TreeNode.SetText(displayName) } } } } } func (t *TTree) ToggleSelectCurrentNode(id string) { for i := range len(t.List) { node := t.List[i] if node.ID == id { t.setNodeSelect(node) return } } } func (t *TTree) setNodeSelect(node *TNode) { if t.IsNodeSelected(node.ID) { displayName := misc.Colorize(node.DisplayName, *misc.TUITheme.ItemSelected) node.TreeNode.SetText(displayName) for _, childNode := range *node.Children { displayName := misc.Colorize(childNode.DisplayName, *misc.TUITheme.ItemSelected) childNode.TreeNode.SetText(displayName) } return } switch node.Type { case "directory": displayName := misc.Colorize(node.DisplayName, *misc.TUITheme.ItemDir) node.TreeNode.SetText(displayName) case "task": displayName := misc.Colorize(node.DisplayName, *misc.TUITheme.Item) node.TreeNode.SetText(displayName) for _, childNode := range *node.Children { if childNode.Type == "task-ref" { displayName := misc.Colorize(childNode.DisplayName, *misc.TUITheme.ItemRef) childNode.TreeNode.SetText(displayName) } else { displayName := misc.Colorize(childNode.DisplayName, *misc.TUITheme.Item) childNode.TreeNode.SetText(displayName) } } case "project": displayName := misc.Colorize(node.DisplayName, *misc.TUITheme.Item) node.TreeNode.SetText(displayName) default: displayName := misc.Colorize(node.DisplayName, *misc.TUITheme.Item) node.TreeNode.SetText(displayName) } } func (t *TTree) FocusFirst() { t.Tree.SetCurrentNode(t.RootNode) } func (t *TTree) FocusLast() { children := t.RootNode.GetChildren() last := children[len(children)-1] name := last.GetReference().(string) if name == "" { children = last.GetChildren() last = children[len(children)-1] } t.Tree.SetCurrentNode(last) } func (t *TTree) ClearFilter() { CloseFilter(t.Filter) *t.FilterValue = "" } func (t *TTree) applyFilter() { *t.FilterValue = t.Filter.GetText() } func (t *TTree) getVisibleNodes() []*tview.TreeNode { var nodes []*tview.TreeNode var walk func(*tview.TreeNode) walk = func(node *tview.TreeNode) { if node == nil { return } ref := node.GetReference() if ref != nil && ref.(string) != "" { nodes = append(nodes, node) } if node.IsExpanded() { for _, child := range node.GetChildren() { walk(child) } } } walk(t.RootNode) return nodes } func (t *TTree) findNodeIndex(nodes []*tview.TreeNode, target *tview.TreeNode) int { for i, node := range nodes { if node == target { return i } } return 0 } ================================================ FILE: core/tui/misc/tui_event.go ================================================ package misc import ( "sync" ) type Event struct { Name string Data interface{} } type EventListener func(Event) type EventEmitter struct { listeners map[string][]EventListener mu sync.RWMutex } func NewEventEmitter() *EventEmitter { return &EventEmitter{ listeners: make(map[string][]EventListener), } } func (ee *EventEmitter) Subscribe(eventName string, listener EventListener) { ee.mu.Lock() defer ee.mu.Unlock() ee.listeners[eventName] = append(ee.listeners[eventName], listener) } func (ee *EventEmitter) Publish(event Event) { ee.mu.RLock() defer ee.mu.RUnlock() if listeners, ok := ee.listeners[event.Name]; ok { for _, listener := range listeners { go listener(event) } } } func (ee *EventEmitter) PublishAndWait(event Event) { ee.mu.RLock() listeners := ee.listeners[event.Name] ee.mu.RUnlock() var wg sync.WaitGroup for _, listener := range listeners { wg.Add(1) go func(l EventListener) { defer wg.Done() l(event) }(listener) } wg.Wait() } ================================================ FILE: core/tui/misc/tui_focus.go ================================================ package misc import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) type TItem struct { Primitive tview.Primitive Box *tview.Box } func FocusNext(elements []*TItem) *tview.Primitive { if len(elements) == 0 { return nil } currentFocus := App.GetFocus() nextIndex := -1 var nextFocusItem TItem for i, element := range elements { if element.Primitive == currentFocus { nextIndex = (i + 1) % len(elements) nextFocusItem = *elements[nextIndex] } element.Box.SetBorderColor(STYLE_BORDER.Fg) } // In-case no nextIndex is found, use the previous page as base to find nextFocusItem if nextIndex < 0 { for i, element := range elements { if element.Primitive == PreviousPane { nextIndex = (i + 1) % len(elements) nextFocusItem = *elements[nextIndex] } } } // Fallback to first element if still not found if nextIndex < 0 { nextFocusItem = *elements[0] } // Set border and focus nextFocusItem.Box.SetBorderColor(STYLE_BORDER_FOCUS.Fg) App.SetFocus(nextFocusItem.Primitive) return &nextFocusItem.Primitive } func FocusPrevious(elements []*TItem) *tview.Primitive { if len(elements) == 0 { return nil } currentFocus := App.GetFocus() prevIndex := -1 var nextFocusItem TItem for i, element := range elements { if element.Primitive == currentFocus { prevIndex = (i - 1 + len(elements)) % len(elements) nextFocusItem = *elements[prevIndex] } element.Box.SetBorderColor(STYLE_BORDER.Fg) } // In-case no prevIndex is found, use the previous page as base to find nextFocusItem if prevIndex < 0 { for i, element := range elements { if element.Primitive == PreviousPane { prevIndex = (i - 1 + len(elements)) % len(elements) nextFocusItem = *elements[prevIndex] } } } // Fallback to first element if still not found if prevIndex < 0 { nextFocusItem = *elements[0] } // Set border and focus nextFocusItem.Box.SetBorderColor(STYLE_BORDER_FOCUS.Fg) App.SetFocus(nextFocusItem.Primitive) return &nextFocusItem.Primitive } func FocusPage(event *tcell.EventKey, focusable []*TItem) { i := int(event.Rune()-'0') - 1 if i < len(focusable) { App.SetFocus(focusable[i].Box) } } func FocusPreviousPage() { App.SetFocus(PreviousPane) } func GetTUIItem(primitive tview.Primitive, box *tview.Box) *TItem { return &TItem{ Primitive: primitive, Box: box, } } ================================================ FILE: core/tui/misc/tui_global.go ================================================ package misc import ( "github.com/alajmo/mani/core/dao" "github.com/rivo/tview" ) var Config *dao.Config var ThemeName *string var TUITheme *dao.TUI var BlockTheme *dao.Block var App *tview.Application var Pages *tview.Pages var MainPage *tview.Pages var PreviousPane tview.Primitive var PreviousModel interface{} // Nav var ProjectBtn *tview.Button var TaskBtn *tview.Button var RunBtn *tview.Button var ExecBtn *tview.Button var HelpBtn *tview.Button var ProjectsLastFocus *tview.Primitive var TasksLastFocus *tview.Primitive var RunLastFocus *tview.Primitive var ExecLastFocus *tview.Primitive // Misc var HelpModal *tview.Modal var Search *tview.InputField ================================================ FILE: core/tui/misc/tui_theme.go ================================================ package misc import ( "fmt" "strings" "github.com/alajmo/mani/core/dao" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) // Default var STYLE_DEFAULT StyleOption // Border var STYLE_BORDER StyleOption var STYLE_BORDER_FOCUS StyleOption // Title var STYLE_TITLE StyleOption var STYLE_TITLE_ACTIVE StyleOption // Table Header var STYLE_TABLE_HEADER StyleOption // Item var STYLE_ITEM StyleOption var STYLE_ITEM_FOCUSED StyleOption var STYLE_ITEM_SELECTED StyleOption // Button var STYLE_BUTTON StyleOption var STYLE_BUTTON_ACTIVE StyleOption // Search var STYLE_SEARCH_LABEL StyleOption var STYLE_SEARCH_TEXT StyleOption // Filter var STYLE_FILTER_LABEL StyleOption var STYLE_FILTER_TEXT StyleOption // Shortcut var STYLE_SHORTCUT_LABEL StyleOption var STYLE_SHORTCUT_TEXT StyleOption type StyleOption struct { Fg tcell.Color Bg tcell.Color Attr tcell.AttrMask Align int FgStr string BgStr string AttrStr string AlignStr string FormatStr string Style tcell.Style } func LoadStyles(tui *dao.TUI) { // Default STYLE_DEFAULT = initStyle(tui.Default) // Border STYLE_BORDER = initStyle(tui.Border) STYLE_BORDER_FOCUS = initStyle(tui.BorderFocus) // Title STYLE_TITLE = initStyle(tui.Title) STYLE_TITLE_ACTIVE = initStyle(tui.TitleActive) // Table Header STYLE_TABLE_HEADER = initStyle(tui.TableHeader) // Item STYLE_ITEM = initStyle(tui.Item) STYLE_ITEM_FOCUSED = initStyle(tui.ItemFocused) STYLE_ITEM_SELECTED = initStyle(tui.ItemSelected) // Button STYLE_BUTTON = initStyle(tui.Button) STYLE_BUTTON_ACTIVE = initStyle(tui.ButtonActive) // Search STYLE_SEARCH_LABEL = initStyle(tui.SearchLabel) STYLE_SEARCH_TEXT = initStyle(tui.SearchText) // Filter STYLE_FILTER_LABEL = initStyle(tui.FilterLabel) STYLE_FILTER_TEXT = initStyle(tui.FilterText) // Shortcut STYLE_SHORTCUT_LABEL = initStyle(tui.ShortcutLabel) STYLE_SHORTCUT_TEXT = initStyle(tui.ShortcutText) } func initStyle(opts *dao.ColorOptions) StyleOption { fg := tcell.GetColor(*opts.Fg) bg := tcell.GetColor(*opts.Bg) attr := getAttr(*opts.Attr) style := StyleOption{ Fg: fg, Bg: bg, Attr: attr, Align: getAlign(opts.Align), FgStr: *opts.Fg, BgStr: *opts.Bg, AttrStr: *opts.Attr, FormatStr: *opts.Format, Style: tcell.StyleDefault.Foreground(fg).Background(bg).Attributes(attr), } return style } func Colorize(value string, opts dao.ColorOptions) string { return " [-:-:-]" + fmt.Sprintf("[%s:%s:%s]%s", *opts.Fg, *opts.Bg, *opts.Attr, value) + "[-:-:-] " } func ColorizeTitle(value string, opts dao.ColorOptions) string { return " [-:-:-]" + fmt.Sprintf("[%s:%s:%s] %s ", *opts.Fg, *opts.Bg, *opts.Attr, value) + "[-:-:-] " } func getAttr(attrStr string) tcell.AttrMask { var attr tcell.AttrMask switch attrStr { case "b", "bold": attr = tcell.AttrBold case "d", "dim": attr = tcell.AttrDim case "i", "italic": attr = tcell.AttrItalic case "u", "underline": attr = tcell.AttrUnderline default: attr = tcell.AttrNone } return attr } func getAlign(alignStr *string) int { if alignStr == nil { return tview.AlignLeft } lowerAlign := strings.ToLower(*alignStr) switch lowerAlign { case "l", "left": return tview.AlignLeft case "r", "right": return tview.AlignRight case "b", "bottom": return tview.AlignBottom case "t", "top": return tview.AlignTop case "c", "center": return tview.AlignCenter } return tview.AlignLeft } func PadString(name string) string { return " " + strings.TrimSpace(name) + " " } ================================================ FILE: core/tui/misc/tui_utils.go ================================================ package misc import ( "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/print" "github.com/rivo/tview" "golang.org/x/term" ) func SetActive(box *tview.Box, title string, active bool) { if active { box.SetBorderColor(STYLE_BORDER_FOCUS.Fg) box.SetTitleAlign(STYLE_TITLE_ACTIVE.Align) title = dao.StyleFormat(title, STYLE_TITLE_ACTIVE.FormatStr) if title != "" { title = ColorizeTitle(title, *TUITheme.TitleActive) box.SetTitle(title) } } else { box.SetBorderColor(STYLE_BORDER.Fg) box.SetTitleAlign(STYLE_TITLE.Align) title = dao.StyleFormat(title, STYLE_TITLE.FormatStr) if title != "" { title = ColorizeTitle(title, *TUITheme.Title) box.SetTitle(title) } } } func GetTexztModalSize(text string) (int, int) { termWidth, termHeight, _ := term.GetSize(0) textWidth, textHeight := print.GetTextDimensions(text) width := textWidth height := textHeight // Min Width - sane minimum default width if width < 45 { width = 45 } // Max Width - can't be wider than terminal width if width > termWidth { width = termWidth - 20 // Add some margin left/right height = height + 4 // Since text wraps, add some margin to height } // Max Height - can't be taller than terminal width if height > termHeight { height = termHeight - 5 // Add some margin top/bottom } width += 8 // Add some padding height += 2 // Add some padding return width, height } ================================================ FILE: core/tui/misc/tui_writer.go ================================================ package misc import ( "io" "sync" "github.com/rivo/tview" ) // ThreadSafeWriter wraps a tview.ANSIWriter to make it thread-safe type ThreadSafeWriter struct { writer io.Writer mutex sync.Mutex } // NewThreadSafeWriter creates a new thread-safe writer for tview func NewThreadSafeWriter(view *tview.TextView) *ThreadSafeWriter { return &ThreadSafeWriter{ writer: tview.ANSIWriter(view), } } // Write implements io.Writer interface in a thread-safe manner func (w *ThreadSafeWriter) Write(p []byte) (n int, err error) { w.mutex.Lock() defer w.mutex.Unlock() return w.writer.Write(p) } ================================================ FILE: core/tui/pages/tui_exec.go ================================================ package pages import ( "github.com/gdamore/tcell/v2" "github.com/jinzhu/copier" "github.com/rivo/tview" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/exec" "github.com/alajmo/mani/core/tui/components" "github.com/alajmo/mani/core/tui/misc" "github.com/alajmo/mani/core/tui/views" ) type TExecPage struct { focusable []*misc.TItem } func CreateExecPage( projects []dao.Project, projectTags []string, projectPaths []string, ) *tview.Flex { e := &TExecPage{} projectData := views.CreateProjectsData( projects, projectTags, projectPaths, []string{"Project", "Description", "Tag"}, 2, true, true, true, true, true, ) // Views streamView, ansiWriter := components.CreateOutputView("[2] Output") projectInfo := views.CreateRunInfoVIew() cmdInfo := views.CreateExecInfoView() cmdView := components.CreateTextArea("[1] Command") spec := views.CreateSpecView() // Pages execPage := e.createSelectPage(projectData, projectInfo, cmdView) outputPage := e.createOutputPage(cmdInfo, cmdView, streamView) pages := tview.NewPages(). AddPage("exec-projects", execPage, true, true). AddPage("exec-run", outputPage, true, false) // Main page page := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(pages, 0, 1, true). AddItem(misc.Search, 1, 0, false) // Focus e.focusable = e.updateSelectFocusable(*projectData, cmdView) misc.ExecLastFocus = &e.focusable[0].Primitive // Shortcuts page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyCtrlS: e.focusable = e.switchView(pages, projectData, cmdView, streamView) misc.App.SetFocus(e.focusable[0].Primitive) misc.ExecLastFocus = &e.focusable[0].Primitive return nil case tcell.KeyCtrlR: e.focusable = e.switchBeforeRun(pages, e.focusable, cmdView, streamView) misc.App.SetFocus(e.focusable[0].Primitive) misc.ExecLastFocus = &e.focusable[0].Primitive cmd := cmdView.GetText() e.runCmd(streamView, cmd, projectData.Projects, projectData.ProjectsSelected, spec, ansiWriter) return nil } switch event.Key() { case tcell.KeyTab: nextPrimitive := misc.FocusNext(e.focusable) misc.ExecLastFocus = nextPrimitive return nil case tcell.KeyBacktab: nextPrimitive := misc.FocusPrevious(e.focusable) misc.ExecLastFocus = nextPrimitive return nil case tcell.KeyCtrlO: components.OpenModal("spec-modal", "Options", spec.View, 30, 11) return nil case tcell.KeyCtrlX: streamView.Clear() return nil case tcell.KeyRune: if _, ok := misc.App.GetFocus().(*tview.TextArea); ok { return event } name, _ := pages.GetFrontPage() if name == "exec-projects" { switch event.Rune() { case 'C': // Clear filters projectData.Emitter.PublishAndWait(misc.Event{Name: "remove_tag_path_filter", Data: ""}) projectData.Emitter.PublishAndWait(misc.Event{Name: "remove_tag_path_selections", Data: ""}) projectData.Emitter.PublishAndWait(misc.Event{Name: "remove_project_filter", Data: ""}) projectData.Emitter.PublishAndWait(misc.Event{Name: "remove_project_selections", Data: ""}) projectData.Emitter.Publish(misc.Event{Name: "filter_projects", Data: ""}) return nil case '1', '2', '3', '4', '5', '6', '7', '8', '9': misc.FocusPage(event, e.focusable) return nil } } if name == "exec-run" { switch event.Rune() { case '1': misc.App.SetFocus(cmdView) return nil case '2': misc.App.SetFocus(streamView) return nil } } } return event }) return page } func (e *TExecPage) createSelectPage( projectData *views.TProject, infoPane *tview.TextView, execInput *tview.TextArea, ) *tview.Flex { isProjectTable := projectData.ProjectStyle == "project-table" projectPages := tview.NewPages(). AddPage("project-table", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(projectData.ProjectTableView.Root, 0, 1, true), true, isProjectTable). AddPage("project-tree", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(projectData.ProjectTreeView.Root, 0, 8, false), true, !isProjectTable) projectPages.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyCtrlE: if projectData.ProjectStyle == "project-table" { projectData.ProjectStyle = "project-tree" } else { projectData.ProjectStyle = "project-table" } projectPages.SwitchToPage(projectData.ProjectStyle) e.focusable = e.updateSelectFocusable(*projectData, execInput) misc.App.SetFocus(e.focusable[1].Primitive) misc.RunLastFocus = &e.focusable[1].Primitive return nil } return event }) // Always show both panes, even when empty projectData.ContextView = tview.NewFlex().SetDirection(tview.FlexRow) projectData.ContextView.AddItem(projectData.TagView.Root, 0, 1, false) projectData.ContextView.AddItem(projectData.PathView.Root, 0, 1, false) bottom := tview.NewFlex(). SetDirection(tview.FlexColumn). AddItem(projectPages, 0, 1, false). AddItem(projectData.ContextView, 30, 1, false) // Container page := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(execInput, 8, 0, true). AddItem(bottom, 0, 1, false). AddItem(infoPane, 1, 0, false) return page } func (e *TExecPage) createOutputPage( infoPane *tview.TextView, execInput *tview.TextArea, streamView *tview.TextView, ) *tview.Flex { outputView := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(execInput, 8, 0, true). AddItem(streamView, 0, 1, false). AddItem(infoPane, 1, 0, false) return outputView } func (e *TExecPage) updateSelectFocusable( projectData views.TProject, execInput *tview.TextArea, ) []*misc.TItem { focusable := []*misc.TItem{ misc.GetTUIItem( execInput, execInput.Box, ), } // Project if projectData.ProjectStyle == "project-table" { focusable = append( focusable, misc.GetTUIItem( projectData.ProjectTableView.Table, projectData.ProjectTableView.Table.Box, )) } else { focusable = append( focusable, misc.GetTUIItem( projectData.ProjectTreeView.Tree, projectData.ProjectTreeView.Tree.Box, )) } // Always include Tags and Paths panes (even when empty) focusable = append( focusable, misc.GetTUIItem( projectData.TagView.List, projectData.TagView.List.Box, )) focusable = append( focusable, misc.GetTUIItem( projectData.PathView.List, projectData.PathView.List.Box, )) return focusable } func (e *TExecPage) updateStreamFocusable( execInput *tview.TextArea, streamView *tview.TextView, ) []*misc.TItem { focusable := []*misc.TItem{ misc.GetTUIItem(execInput, execInput.Box), misc.GetTUIItem(streamView, streamView.Box), } return focusable } func (e *TExecPage) switchView( pages *tview.Pages, data *views.TProject, cmdView *tview.TextArea, streamView *tview.TextView, ) []*misc.TItem { name, _ := pages.GetFrontPage() var focusable []*misc.TItem if name == "exec-run" { pages.SwitchToPage("exec-projects") focusable = e.updateSelectFocusable(*data, cmdView) } else { pages.SwitchToPage("exec-run") focusable = e.updateStreamFocusable(cmdView, streamView) } return focusable } func (e *TExecPage) switchBeforeRun( pages *tview.Pages, focusable []*misc.TItem, cmdView *tview.TextArea, streamView *tview.TextView, ) []*misc.TItem { name, _ := pages.GetFrontPage() if name == "exec-projects" { pages.SwitchToPage("exec-run") focusable = e.updateStreamFocusable(cmdView, streamView) } return focusable } func (e *TExecPage) runCmd( streamView *tview.TextView, cmd string, projects []dao.Project, projectsSelectMap map[string]bool, spec *views.TSpec, ansiWriter *misc.ThreadSafeWriter, ) { // Check if any projects selected selectedProjects := []dao.Project{} for _, project := range projects { if projectsSelectMap[project.Name] { selectedProjects = append(selectedProjects, project) } } if len(selectedProjects) < 1 { return } // Task task := dao.Task{Name: "", Cmd: cmd} taskErrors := make([]dao.ResourceErrors[dao.Task], 1) task.ParseTask(*misc.Config, &taskErrors[0]) task.SpecData.Output = spec.Output task.SpecData.Parallel = spec.Parallel task.SpecData.IgnoreErrors = spec.IgnoreErrors task.SpecData.IgnoreNonExisting = spec.IgnoreNonExisting task.SpecData.OmitEmptyRows = spec.OmitEmptyRows task.SpecData.OmitEmptyColumns = spec.OmitEmptyColumns // Flags runFlags := core.RunFlags{ Silent: true, // Target Cwd: false, All: false, TagsExpr: "", Target: "default", Spec: "default", Output: spec.Output, Parallel: spec.Parallel, IgnoreErrors: spec.IgnoreErrors, IgnoreNonExisting: spec.IgnoreNonExisting, OmitEmptyRows: spec.OmitEmptyRows, OmitEmptyColumns: spec.OmitEmptyColumns, } setRunFlags := core.SetRunFlags{ Parallel: spec.Parallel, All: true, Cwd: true, IgnoreErrors: true, IgnoreNonExisting: true, OmitEmptyRows: true, OmitEmptyColumns: true, } // Preprocess var tasks []dao.Task for range selectedProjects { t := dao.Task{} err := copier.Copy(&t, &task) core.CheckIfError(err) tasks = append(tasks, t) } // Run target := exec.Exec{Projects: selectedProjects, Tasks: tasks, Config: *misc.Config} if spec.ClearBeforeRun { streamView.Clear() } if spec.Output == "table" { text := streamView.GetText(false) streamView.SetText(text + "\n") } else { text := streamView.GetText(false) streamView.SetText(text + "\n") } err := target.RunTUI([]string{}, &runFlags, &setRunFlags, spec.Output, ansiWriter, ansiWriter) core.CheckIfError(err) streamView.ScrollToEnd() } ================================================ FILE: core/tui/pages/tui_project.go ================================================ package pages import ( "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/tui/misc" "github.com/alajmo/mani/core/tui/views" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) type TProjectPage struct { focusable []*misc.TItem } func CreateProjectsPage( projects []dao.Project, projectTags []string, projectPaths []string, ) *tview.Flex { p := &TProjectPage{} // Data projectData := views.CreateProjectsData( projects, projectTags, projectPaths, []string{"Project", "Description", "Tag", "Url", "Path"}, 1, true, true, false, true, true, ) // Views projectInfo := views.CreateProjectInfoView() projectTablePage := p.createProjectPage(projectData) // Context page (always show both panes, even when empty) projectData.ContextView = tview.NewFlex().SetDirection(tview.FlexRow) projectData.ContextView.AddItem(projectData.TagView.Root, 0, 1, true) projectData.ContextView.AddItem(projectData.PathView.Root, 0, 1, true) // Page projectData.Page = tview.NewFlex(). SetDirection(tview.FlexRow). AddItem( tview.NewFlex().SetDirection(tview.FlexColumn). AddItem(projectTablePage, 0, 1, true). AddItem(projectData.ContextView, 30, 1, false), 0, 1, true). AddItem(projectInfo, 1, 0, false). AddItem(misc.Search, 1, 0, false) // Focusable p.focusable = p.updateProjectFocusable(projectData) misc.ProjectsLastFocus = &p.focusable[0].Primitive // Shortcuts projectData.Page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if misc.App.GetFocus() == misc.Search { return event } switch event.Key() { case tcell.KeyTab: nextPrimitive := misc.FocusNext(p.focusable) misc.ProjectsLastFocus = nextPrimitive return nil case tcell.KeyBacktab: nextPrimitive := misc.FocusPrevious(p.focusable) misc.ProjectsLastFocus = nextPrimitive return nil case tcell.KeyRune: switch event.Rune() { case 'C': // Clear filters projectData.Emitter.PublishAndWait(misc.Event{Name: "remove_tag_path_filter", Data: ""}) projectData.Emitter.PublishAndWait(misc.Event{Name: "remove_tag_path_selections", Data: ""}) projectData.Emitter.PublishAndWait(misc.Event{Name: "remove_project_filter", Data: ""}) projectData.Emitter.PublishAndWait(misc.Event{Name: "remove_project_selections", Data: ""}) projectData.Emitter.Publish(misc.Event{Name: "filter_projects", Data: ""}) return nil case '1', '2', '3', '4', '5', '6', '7', '8', '9': misc.FocusPage(event, p.focusable) return nil } } return event }) return projectData.Page } func (p *TProjectPage) createProjectPage(projectData *views.TProject) *tview.Flex { isTable := projectData.ProjectStyle == "project-table" pages := tview.NewPages(). AddPage("project-table", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(projectData.ProjectTableView.Root, 0, 1, true), true, isTable). AddPage("project-tree", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(projectData.ProjectTreeView.Root, 0, 8, false), true, !isTable) page := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(pages, 0, 1, true) page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if misc.App.GetFocus() == misc.Search { return event } switch event.Key() { case tcell.KeyCtrlE: if projectData.ProjectStyle == "project-table" { projectData.ProjectStyle = "project-tree" } else { projectData.ProjectStyle = "project-table" } pages.SwitchToPage(projectData.ProjectStyle) p.focusable = p.updateProjectFocusable(projectData) misc.App.SetFocus(p.focusable[0].Primitive) misc.ProjectsLastFocus = &p.focusable[0].Primitive return nil } return event }) return page } func (p *TProjectPage) updateProjectFocusable( data *views.TProject, ) []*misc.TItem { focusable := []*misc.TItem{} if data.ProjectStyle == "project-table" { focusable = append( focusable, misc.GetTUIItem( data.ProjectTableView.Table, data.ProjectTableView.Table.Box, )) } else { focusable = append( focusable, misc.GetTUIItem( data.ProjectTreeView.Tree, data.ProjectTreeView.Tree.Box, )) } // Always include Tags and Paths panes (even when empty) focusable = append( focusable, misc.GetTUIItem( data.TagView.List, data.TagView.List.Box)) focusable = append( focusable, misc.GetTUIItem( data.PathView.List, data.PathView.List.Box)) return focusable } ================================================ FILE: core/tui/pages/tui_run.go ================================================ package pages import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/exec" "github.com/alajmo/mani/core/tui/components" "github.com/alajmo/mani/core/tui/misc" "github.com/alajmo/mani/core/tui/views" ) type TRunPage struct { focusable []*misc.TItem } func CreateRunPage( tasks []dao.Task, projects []dao.Project, projectTags []string, projectPaths []string, ) *tview.Flex { r := &TRunPage{} // Data taskData := views.CreateTasksData( tasks, []string{"Name", "Description"}, 1, true, true, true, ) projectData := views.CreateProjectsData( projects, projectTags, projectPaths, []string{"Project", "Description", "Tag"}, 2, true, true, true, true, true, ) // Views streamView, ansiWriter := components.CreateOutputView("[1] Output") runInfoView := views.CreateRunInfoVIew() execInfoView := views.CreateExecInfoView() spec := views.CreateSpecView() // Pages runPage := r.createSelectPage(taskData, projectData, runInfoView) outputPage := r.createOutputPage(execInfoView, streamView) pages := tview.NewPages(). AddPage("exec-projects", runPage, true, true). AddPage("exec-run", outputPage, true, false) // Main page page := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(pages, 0, 1, true). AddItem(misc.Search, 1, 0, false) // Focus r.focusable = r.updateRunFocusable(*taskData, *projectData) misc.RunLastFocus = &r.focusable[0].Primitive // Shortcuts page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyCtrlS: r.focusable = r.switchView(pages, taskData, projectData, streamView) misc.App.SetFocus(r.focusable[0].Primitive) misc.RunLastFocus = &r.focusable[0].Primitive return nil case tcell.KeyCtrlR: r.focusable = r.switchBeforeRun(pages, r.focusable, streamView) misc.App.SetFocus(r.focusable[0].Primitive) misc.RunLastFocus = &r.focusable[0].Primitive r.runTasks(streamView, *taskData, *projectData, spec, ansiWriter) return nil } switch event.Key() { case tcell.KeyTab: nextPrimitive := misc.FocusNext(r.focusable) misc.RunLastFocus = nextPrimitive return nil case tcell.KeyBacktab: nextPrimitive := misc.FocusPrevious(r.focusable) misc.RunLastFocus = nextPrimitive return nil case tcell.KeyCtrlO: components.OpenModal("spec-modal", "Options", spec.View, 30, 11) return nil case tcell.KeyCtrlX: streamView.Clear() return nil case tcell.KeyRune: if _, ok := misc.App.GetFocus().(*tview.InputField); ok { return event } name, _ := pages.GetFrontPage() if name == "exec-projects" { switch event.Rune() { case 'C': // Clear filters projectData.Emitter.PublishAndWait(misc.Event{Name: "remove_tag_path_filter", Data: ""}) projectData.Emitter.PublishAndWait(misc.Event{Name: "remove_tag_path_selections", Data: ""}) projectData.Emitter.PublishAndWait(misc.Event{Name: "remove_project_filter", Data: ""}) projectData.Emitter.PublishAndWait(misc.Event{Name: "remove_project_selections", Data: ""}) projectData.Emitter.Publish(misc.Event{Name: "filter_projects", Data: ""}) taskData.Emitter.PublishAndWait(misc.Event{Name: "remove_task_filter", Data: ""}) taskData.Emitter.PublishAndWait(misc.Event{Name: "remove_task_selections", Data: ""}) taskData.Emitter.Publish(misc.Event{Name: "filter_tasks", Data: ""}) return nil case '1', '2', '3', '4', '5', '6', '7', '8', '9': misc.FocusPage(event, r.focusable) return nil } } } return event }) return page } func (r *TRunPage) createSelectPage( taskData *views.TTask, projectData *views.TProject, info *tview.TextView, ) *tview.Flex { // Tasks isTaskTable := taskData.TaskStyle == "task-table" taskPages := tview.NewPages(). AddPage( "task-table", tview.NewFlex().SetDirection(tview.FlexRow). AddItem(taskData.TaskTableView.Root, 0, 1, true), true, isTaskTable, ). AddPage( "task-tree", tview.NewFlex().SetDirection(tview.FlexRow). AddItem(taskData.TaskTreeView.Root, 0, 8, false), true, !isTaskTable, ) taskPages.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyCtrlE: if taskData.TaskStyle == "task-table" { taskData.TaskStyle = "task-tree" } else { taskData.TaskStyle = "task-table" } taskPages.SwitchToPage(taskData.TaskStyle) r.focusable = r.updateRunFocusable(*taskData, *projectData) misc.App.SetFocus(r.focusable[0].Primitive) misc.RunLastFocus = &r.focusable[0].Primitive return nil } return event }) // Projects isProjectTable := projectData.ProjectStyle == "project-table" projectPages := tview.NewPages(). AddPage("project-table", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(projectData.ProjectTableView.Root, 0, 1, true), true, isProjectTable). AddPage("project-tree", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(projectData.ProjectTreeView.Root, 0, 8, false), true, !isProjectTable) projectPages.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyCtrlE: if projectData.ProjectStyle == "project-table" { projectData.ProjectStyle = "project-tree" } else { projectData.ProjectStyle = "project-table" } projectPages.SwitchToPage(projectData.ProjectStyle) r.focusable = r.updateRunFocusable(*taskData, *projectData) misc.App.SetFocus(r.focusable[1].Primitive) misc.RunLastFocus = &r.focusable[1].Primitive return nil } return event }) // Always show both panes, even when empty projectData.ContextView = tview.NewFlex().SetDirection(tview.FlexRow) projectData.ContextView.AddItem(projectData.TagView.Root, 0, 1, true) projectData.ContextView.AddItem(projectData.PathView.Root, 0, 1, true) taskProjects := tview.NewFlex(). SetDirection(tview.FlexColumn). AddItem(projectPages, 0, 1, true). AddItem(projectData.ContextView, 30, 1, false) page := tview.NewFlex(). SetDirection(tview.FlexColumn). AddItem(taskPages, 0, 1, true). AddItem(taskProjects, 0, 1, false) return tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(page, 0, 1, true). AddItem(info, 1, 0, false) } func (r *TRunPage) createOutputPage( info *tview.TextView, streamView *tview.TextView, ) *tview.Flex { outputView := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(streamView, 0, 1, false). AddItem(info, 1, 0, true) return outputView } func (r *TRunPage) updateRunFocusable( taskData views.TTask, projectData views.TProject, ) []*misc.TItem { focusable := []*misc.TItem{} // Task if taskData.TaskStyle == "task-table" { focusable = append( focusable, misc.GetTUIItem( taskData.TaskTableView.Table, taskData.TaskTableView.Table.Box, )) } else { focusable = append( focusable, misc.GetTUIItem( taskData.TaskTreeView.Tree, taskData.TaskTreeView.Tree.Box, )) } // Project if projectData.ProjectStyle == "project-table" { focusable = append( focusable, misc.GetTUIItem( projectData.ProjectTableView.Table, projectData.ProjectTableView.Table.Box, )) } else { focusable = append( focusable, misc.GetTUIItem( projectData.ProjectTreeView.Tree, projectData.ProjectTreeView.Tree.Box, )) } // Project Context (always include Tags and Paths panes, even when empty) focusable = append( focusable, misc.GetTUIItem( projectData.TagView.List, projectData.TagView.List.Box), ) focusable = append( focusable, misc.GetTUIItem( projectData.PathView.List, projectData.PathView.List.Box), ) return focusable } func (r *TRunPage) updateStreamFocusable(streamView *tview.TextView) []*misc.TItem { focusable := []*misc.TItem{ misc.GetTUIItem(streamView, streamView.Box), } return focusable } func (r *TRunPage) switchView( pages *tview.Pages, taskData *views.TTask, projectData *views.TProject, streamView *tview.TextView, ) []*misc.TItem { name, _ := pages.GetFrontPage() var focusable []*misc.TItem if name == "exec-run" { pages.SwitchToPage("exec-projects") focusable = r.updateRunFocusable(*taskData, *projectData) } else { pages.SwitchToPage("exec-run") focusable = r.updateStreamFocusable(streamView) } return focusable } func (r *TRunPage) switchBeforeRun( pages *tview.Pages, focusable []*misc.TItem, streamView *tview.TextView, ) []*misc.TItem { name, _ := pages.GetFrontPage() if name == "exec-projects" { pages.SwitchToPage("exec-run") focusable = r.updateStreamFocusable(streamView) } return focusable } func (r *TRunPage) runTasks( streamView *tview.TextView, taskData views.TTask, projectData views.TProject, spec *views.TSpec, ansiWriter *misc.ThreadSafeWriter, ) { // Check if any projects selected selectedProjects := []dao.Project{} for _, project := range projectData.Projects { if projectData.ProjectsSelected[project.Name] { selectedProjects = append(selectedProjects, project) } } if len(selectedProjects) < 1 { return } // Task var taskNames []string for _, task := range taskData.Tasks { if taskData.TasksSelected[task.Name] { taskNames = append(taskNames, task.Name) } } var projectNames []string for _, project := range selectedProjects { projectNames = append(projectNames, project.Name) } // Flags runFlags := core.RunFlags{ Silent: true, // Filter Cwd: false, All: false, TagsExpr: "", Target: "default", Spec: "default", Projects: projectNames, Output: spec.Output, Parallel: spec.Parallel, IgnoreErrors: spec.IgnoreErrors, IgnoreNonExisting: spec.IgnoreNonExisting, OmitEmptyRows: spec.OmitEmptyRows, OmitEmptyColumns: spec.OmitEmptyColumns, } setRunFlags := core.SetRunFlags{ Parallel: spec.Parallel, All: true, Cwd: true, IgnoreErrors: true, IgnoreNonExisting: true, OmitEmptyRows: true, OmitEmptyColumns: true, } // Parse Task var err error var tasks []dao.Task var projects []dao.Project if len(taskNames) == 1 { tasks, projects, err = dao.ParseSingleTask(taskNames[0], &runFlags, &setRunFlags, misc.Config) } else { tasks, projects, err = dao.ParseManyTasks(taskNames, &runFlags, &setRunFlags, misc.Config) } if err != nil { misc.App.Stop() } // Run task target := exec.Exec{Projects: projects, Tasks: tasks, Config: *misc.Config} if spec.ClearBeforeRun { streamView.Clear() } if spec.Output == "table" { text := streamView.GetText(false) streamView.SetText(text + "\n") } else { text := streamView.GetText(false) streamView.SetText(text + "\n") } err = target.RunTUI([]string{}, &runFlags, &setRunFlags, spec.Output, ansiWriter, ansiWriter) if err != nil { misc.App.Stop() } streamView.ScrollToEnd() } ================================================ FILE: core/tui/pages/tui_task.go ================================================ package pages import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/tui/misc" "github.com/alajmo/mani/core/tui/views" ) type TTaskPage struct { focusable []*misc.TItem } func CreateTasksPage(tasks []dao.Task) *tview.Flex { t := &TTaskPage{} // Data taskData := views.CreateTasksData( tasks, []string{"Task", "Description", "Target", "Spec"}, 1, true, true, false, ) // Views taskInfo := views.CreateTaskInfoView() // Pages taskTablePage := t.createTaskPage(taskData) taskData.Page = tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(taskTablePage, 0, 1, true). AddItem(taskInfo, 1, 0, false). AddItem(misc.Search, 1, 0, false) t.focusable = t.updateTaskFocusable(taskData) misc.TasksLastFocus = &t.focusable[0].Primitive // Shortcuts taskData.Page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if misc.App.GetFocus() == misc.Search { return event } switch event.Key() { case tcell.KeyTab: nextPrimitive := misc.FocusNext(t.focusable) misc.TasksLastFocus = nextPrimitive return nil case tcell.KeyBacktab: nextPrimitive := misc.FocusPrevious(t.focusable) misc.TasksLastFocus = nextPrimitive return nil case tcell.KeyRune: if _, ok := misc.App.GetFocus().(*tview.InputField); ok { return event } switch event.Rune() { case 'C': // Clear filters taskData.Emitter.PublishAndWait(misc.Event{Name: "remove_task_filter", Data: ""}) taskData.Emitter.PublishAndWait(misc.Event{Name: "remove_task_selections", Data: ""}) taskData.Emitter.Publish(misc.Event{Name: "filter_tasks", Data: ""}) return nil } } return event }) return taskData.Page } func (taskPage *TTaskPage) createTaskPage(taskData *views.TTask) *tview.Flex { isTable := taskData.TaskStyle == "task-table" pages := tview.NewPages(). AddPage("task-table", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(taskData.TaskTableView.Root, 0, 1, true), true, isTable). AddPage("task-tree", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(taskData.TaskTreeView.Root, 0, 8, false), true, !isTable) page := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(pages, 0, 1, true) page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if misc.App.GetFocus() == misc.Search { return event } switch event.Key() { case tcell.KeyCtrlE: if taskData.TaskStyle == "task-table" { taskData.TaskStyle = "task-tree" } else { taskData.TaskStyle = "task-table" } pages.SwitchToPage(taskData.TaskStyle) taskPage.focusable = taskPage.updateTaskFocusable(taskData) misc.App.SetFocus(taskPage.focusable[0].Primitive) misc.TasksLastFocus = &taskPage.focusable[0].Primitive return nil } return event }) return page } func (taskPage *TTaskPage) updateTaskFocusable( data *views.TTask, ) []*misc.TItem { focusable := []*misc.TItem{} if data.TaskStyle == "task-table" { focusable = append( focusable, misc.GetTUIItem( data.TaskTableView.Table, data.TaskTableView.Table.Box, )) } else { focusable = append( focusable, misc.GetTUIItem( data.TaskTreeView.Tree, data.TaskTreeView.Tree.Box, )) } return focusable } ================================================ FILE: core/tui/pages.go ================================================ package tui import ( "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/tui/components" "github.com/alajmo/mani/core/tui/misc" "github.com/alajmo/mani/core/tui/pages" "github.com/alajmo/mani/core/tui/views" "github.com/rivo/tview" ) func createPages( projects []dao.Project, projectTags []string, projectPaths []string, tasks []dao.Task, ) *tview.Pages { appPages := tview.NewPages() navPane := createNav() search := components.CreateSearch() misc.Search = search projectsPage := pages.CreateProjectsPage(projects, projectTags, projectPaths) tasksPage := pages.CreateTasksPage(tasks) runPage := pages.CreateRunPage(tasks, projects, projectTags, projectPaths) execPage := pages.CreateExecPage(projects, projectTags, projectPaths) misc.MainPage = tview.NewPages(). AddPage("run", runPage, true, true). AddPage("exec", execPage, true, false). AddPage("projects", projectsPage, true, false). AddPage("tasks", tasksPage, true, false) mainLayout := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(navPane, 2, 1, false). AddItem(misc.MainPage, 0, 1, true) appPages.AddPage("main", mainLayout, true, true) SwitchToPage("run") return appPages } func createNav() *tview.Flex { // Buttons misc.ProjectBtn = components.CreateButton("Projects") misc.ProjectBtn.SetSelectedFunc(func() { SwitchToPage("projects") misc.App.SetFocus(*misc.ProjectsLastFocus) }) misc.TaskBtn = components.CreateButton("Tasks") misc.TaskBtn.SetSelectedFunc(func() { SwitchToPage("tasks") misc.App.SetFocus(*misc.TasksLastFocus) }) misc.RunBtn = components.CreateButton("Run") misc.RunBtn.SetSelectedFunc(func() { SwitchToPage("run") misc.App.SetFocus(*misc.RunLastFocus) }) misc.ExecBtn = components.CreateButton("Exec") misc.ExecBtn.SetSelectedFunc(func() { SwitchToPage("exec") misc.App.SetFocus(*misc.ExecLastFocus) }) misc.HelpBtn = components.CreateButton("Help") misc.HelpBtn.SetSelectedFunc(func() { views.ShowHelpModal() }) // Left left := tview.NewFlex(). SetDirection(tview.FlexColumn). AddItem(misc.RunBtn, 7, 0, false). // 3 size + 2 padding AddItem(misc.ExecBtn, 8, 0, false). // 4 size + 2 padding AddItem(misc.ProjectBtn, 12, 0, false). // 8 size + 2 padding AddItem(misc.TaskBtn, 9, 0, false) // 5 size + 2 padding // Right right := tview.NewFlex(). SetDirection(tview.FlexColumn). AddItem(misc.HelpBtn, 5, 0, false) // Nav navPane := tview.NewFlex(). SetDirection(tview.FlexColumn). AddItem(left, 0, 1, false). AddItem(nil, 0, 1, false). AddItem(right, 4, 0, false) navPane.SetBorderPadding(0, 1, 1, 1) return navPane } func SwitchToPage(pageName string) { misc.MainPage.SwitchToPage(pageName) switch pageName { case "projects": components.SetActiveButtonStyle(misc.ProjectBtn) components.SetInactiveButtonStyle(misc.HelpBtn) components.SetInactiveButtonStyle(misc.RunBtn) components.SetInactiveButtonStyle(misc.TaskBtn) components.SetInactiveButtonStyle(misc.ExecBtn) case "tasks": components.SetActiveButtonStyle(misc.TaskBtn) components.SetInactiveButtonStyle(misc.HelpBtn) components.SetInactiveButtonStyle(misc.ProjectBtn) components.SetInactiveButtonStyle(misc.RunBtn) components.SetInactiveButtonStyle(misc.ExecBtn) case "run": components.SetActiveButtonStyle(misc.RunBtn) components.SetInactiveButtonStyle(misc.HelpBtn) components.SetInactiveButtonStyle(misc.ProjectBtn) components.SetInactiveButtonStyle(misc.TaskBtn) components.SetInactiveButtonStyle(misc.ExecBtn) case "exec": components.SetActiveButtonStyle(misc.ExecBtn) components.SetInactiveButtonStyle(misc.HelpBtn) components.SetInactiveButtonStyle(misc.ProjectBtn) components.SetInactiveButtonStyle(misc.TaskBtn) components.SetInactiveButtonStyle(misc.RunBtn) } _, page := misc.MainPage.GetFrontPage() misc.App.SetFocus(page) } func setupStyles() { // Foreground / Background tview.Styles.PrimaryTextColor = misc.STYLE_DEFAULT.Fg tview.Styles.PrimitiveBackgroundColor = misc.STYLE_DEFAULT.Bg // Borders Colors tview.Styles.BorderColor = misc.STYLE_BORDER.Fg // Border style tview.Borders.HorizontalFocus = tview.BoxDrawingsLightHorizontal tview.Borders.VerticalFocus = tview.BoxDrawingsLightVertical tview.Borders.TopLeftFocus = tview.BoxDrawingsLightDownAndRight tview.Borders.TopRightFocus = tview.BoxDrawingsLightDownAndLeft tview.Borders.BottomLeftFocus = tview.BoxDrawingsLightUpAndRight tview.Borders.BottomRightFocus = tview.BoxDrawingsLightUpAndLeft } ================================================ FILE: core/tui/tui.go ================================================ package tui import ( "os" "github.com/alajmo/mani/core" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/tui/misc" "github.com/rivo/tview" ) func RunTui(config *dao.Config, themeName string, reload bool) { app := NewApp(config, themeName) if reload { WatchFiles(app, append([]string{config.Path}, config.ConfigPaths...)...) } if err := app.Run(); err != nil { os.Exit(1) } } type App struct { App *tview.Application } func NewApp(config *dao.Config, themeName string) *App { app := &App{ App: tview.NewApplication(), } app.setupApp(config, themeName) return app } func (app *App) Run() error { return app.App.SetRoot(misc.Pages, true).EnableMouse(true).Run() } func (app *App) Reload() { config, configErr := dao.ReadConfig(misc.Config.Path, "", true) if configErr != nil { app.App.Stop() } app.setupApp(&config, *misc.ThemeName) app.App.SetRoot(misc.Pages, true) app.App.Draw() } func (app *App) setupApp(config *dao.Config, themeName string) { misc.Config = config misc.ThemeName = &themeName theme, err := misc.Config.GetTheme(themeName) core.CheckIfError(err) misc.LoadStyles(&theme.TUI) misc.TUITheme = &theme.TUI misc.BlockTheme = &theme.Block // Data projects := config.ProjectList tasks := config.TaskList dao.ParseTasksEnv(tasks) projectTags := config.GetTags() projectPaths := config.GetProjectPaths() // Styles setupStyles() // Create pages misc.App = app.App misc.Pages = createPages(projects, projectTags, projectPaths, tasks) // Global input handling HandleInput(app) } ================================================ FILE: core/tui/tui_input.go ================================================ package tui import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/alajmo/mani/core/tui/components" "github.com/alajmo/mani/core/tui/misc" "github.com/alajmo/mani/core/tui/views" ) func HandleInput(app *App) { var lastSearchQuery string var lastFoundRow, lastFoundCol int searchDirection := 1 misc.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { currentFocus := misc.App.GetFocus() switch event.Key() { case tcell.KeyF1: SwitchToPage("run") misc.App.SetFocus(*misc.RunLastFocus) return nil case tcell.KeyF2: SwitchToPage("exec") misc.App.SetFocus(*misc.ExecLastFocus) return nil case tcell.KeyF3: SwitchToPage("projects") misc.App.SetFocus(*misc.ProjectsLastFocus) return nil case tcell.KeyF4: SwitchToPage("tasks") misc.App.SetFocus(*misc.TasksLastFocus) return nil case tcell.KeyF5: go app.Reload() return nil case tcell.KeyF6: misc.App.Sync() return nil } // Modal if components.IsModalOpen() { switch event.Key() { case tcell.KeyEscape: components.CloseModal() return nil case tcell.KeyRune: switch event.Rune() { case 'q': misc.App.Stop() return nil } } return event } // Search if currentFocus == misc.Search { lastFoundRow, lastFoundCol = -1, -1 switch event.Key() { case tcell.KeyEscape: components.EmptySearch() misc.FocusPreviousPage() return nil case tcell.KeyEnter: return handleSearchInput(event, searchDirection, &lastFoundRow, &lastFoundCol) } return event } // Input if _, ok := currentFocus.(*tview.InputField); ok { return event } // TextArea if _, ok := currentFocus.(*tview.TextArea); ok { return event } // Main switch event.Key() { case tcell.KeyEscape: components.EmptySearch() return nil case tcell.KeyRune: switch event.Rune() { case 'q': misc.App.Stop() return nil case 'R': misc.App.Sync() return nil case 'p': SwitchToPage("projects") misc.App.SetFocus(*misc.ProjectsLastFocus) return nil case 't': SwitchToPage("tasks") misc.App.SetFocus(*misc.TasksLastFocus) return nil case 'r': SwitchToPage("run") misc.App.SetFocus(*misc.RunLastFocus) return nil case 'e': SwitchToPage("exec") misc.App.SetFocus(*misc.ExecLastFocus) return nil case '?': views.ShowHelpModal() return nil case '/': components.ShowSearch() return nil case 'n': searchDirection = 1 return handleSearchInput(event, searchDirection, &lastFoundRow, &lastFoundCol) case 'N': searchDirection = -1 return handleSearchInput(event, searchDirection, &lastFoundRow, &lastFoundCol) } } return event }) misc.Search.SetChangedFunc(func(query string) { if query != lastSearchQuery { lastSearchQuery = query lastFoundRow, lastFoundCol = -1, -1 searchDirection = 1 switch prevPage := misc.PreviousPane.(type) { case *tview.Table: components.SearchInTable(prevPage, query, &lastFoundRow, &lastFoundCol, searchDirection) case *tview.TreeView: if tree, ok := misc.PreviousModel.(*components.TTree); ok { components.SearchInTree(tree, query, &lastFoundRow, searchDirection) } case *tview.List: components.SearchInList(prevPage, query, &lastFoundRow, searchDirection) } } }) } func handleSearchInput(_ *tcell.EventKey, searchDirection int, lastFoundRow *int, lastFoundCol *int) *tcell.EventKey { query := misc.Search.GetText() if query == "" { return nil } switch prevPage := misc.PreviousPane.(type) { case *tview.Table: misc.App.SetFocus(prevPage) components.SearchInTable(prevPage, query, lastFoundRow, lastFoundCol, searchDirection) case *tview.TreeView: misc.App.SetFocus(prevPage) if tree, ok := misc.PreviousModel.(*components.TTree); ok { components.SearchInTree(tree, query, lastFoundRow, searchDirection) } case *tview.List: misc.App.SetFocus(prevPage) components.SearchInList(prevPage, query, lastFoundRow, searchDirection) } return nil } ================================================ FILE: core/tui/views/tui_help.go ================================================ package views import ( "fmt" "github.com/alajmo/mani/core/tui/components" "github.com/alajmo/mani/core/tui/misc" "github.com/rivo/tview" ) var Version = "v0.31.2" func ShowHelpModal() { t, table := createShortcutsTable() components.OpenModal("help-modal", "Help", t, 65, 37) misc.App.SetFocus(table) } func shortcutRow(shortcut string, description string) (*tview.TableCell, *tview.TableCell) { shortcut = fmt.Sprintf("[%s:%s:%s]%s[-:-:-]", misc.STYLE_SHORTCUT_LABEL.Fg, misc.STYLE_SHORTCUT_LABEL.Bg, misc.STYLE_SHORTCUT_LABEL.AttrStr, shortcut, ) description = fmt.Sprintf("[%s:%s:%s]%s[-:-:-]", misc.STYLE_SHORTCUT_TEXT.Fg, misc.STYLE_SHORTCUT_TEXT.Bg, misc.STYLE_SHORTCUT_TEXT.AttrStr, description, ) r1 := tview.NewTableCell(shortcut + " "). SetTextColor(misc.STYLE_SHORTCUT_TEXT.Fg). SetAlign(tview.AlignRight). SetSelectable(false) r2 := tview.NewTableCell(description). SetAlign(tview.AlignLeft). SetSelectable(false) return r1, r2 } func titleRow(title string) (*tview.TableCell, *tview.TableCell) { r1 := tview.NewTableCell(""). SetTextColor(misc.STYLE_SHORTCUT_TEXT.Fg). SetAlign(tview.AlignRight). SetSelectable(false) r2 := tview.NewTableCell(title). SetTextColor(misc.STYLE_TABLE_HEADER.Fg). SetAttributes(misc.STYLE_TABLE_HEADER.Attr). SetAlign(tview.AlignLeft). SetSelectable(false) return r1, r2 } func createShortcutsTable() (*tview.Flex, *tview.Table) { table := tview.NewTable() table.SetEvaluateAllRows(true) table.SetBackgroundColor(misc.STYLE_DEFAULT.Bg) sections := []struct { title string shortcuts [][2]string }{ { title: "--- Global ---", shortcuts: [][2]string{ {"?", "Show this help"}, {"q, Ctrl + c", "Quits program"}, {"F5", "Reload app"}, {"F6", "Re-sync screen buffer"}, }, }, { title: "--- Navigation ---", shortcuts: [][2]string{ {"r, F1", "Switch to run page"}, {"e, F2", "Switch to exec page"}, {"p, F3", "Switch to projects page"}, {"t, F4", "Switch to tasks page"}, {"1-9", "Focus specific pane"}, {"Tab", "Focus next pane"}, {"Shift + Tab", "Focus previous pane"}, {"g", "Go to first item in the current pane"}, {"G", "Go to last item in the current pane"}, {"Ctrl + o", "Show task options"}, {"Ctrl + s", "Toggle between selection and output view"}, {"Ctrl + e", "Toggle between Table and Tree view"}, }, }, { title: "--- Actions ---", shortcuts: [][2]string{ {"Escape", "Close"}, {"/", "Free text search"}, {"f", "Filter items for the current pane"}, {"F", "Clear filter for the current selected pane"}, {"a", "Select all items in the current pane"}, {"c", "Clear all selections in the current pane"}, {"C", "Clear all filters and selections"}, {"d", "Describe the selected item"}, {"o", "Open the current selected item in $EDITOR"}, {"Space, Enter", "Toggle selection"}, {"Ctrl + r", "Run tasks"}, {"Ctrl + x", "Clear"}, }, }, } // Populate table with sections currentRow := 0 for i, section := range sections { // Add spacing between sections except for the first one if i > 0 { r1, r2 := titleRow("") table.SetCell(currentRow, 0, r1) table.SetCell(currentRow, 1, r2) currentRow++ } // Add section title r1, r2 := titleRow(section.title) table.SetCell(currentRow, 0, r1) table.SetCell(currentRow, 1, r2) currentRow++ // Add shortcuts for this section for _, shortcut := range section.shortcuts { r1, r2 := shortcutRow(shortcut[0], shortcut[1]) table.SetCell(currentRow, 0, r1) table.SetCell(currentRow, 1, r2) currentRow++ } } versionString := fmt.Sprintf("[-:-:b]Mani %s", Version) text := tview.NewTextView() text.SetDynamicColors(true) text.SetText(versionString).SetTextAlign(tview.AlignRight) root := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(text, 1, 0, true). AddItem(table, 0, 1, true) root.SetBorder(true) root.SetBorderPadding(0, 0, 2, 1) root.SetBorderColor(misc.STYLE_BORDER_FOCUS.Fg) return root, table } ================================================ FILE: core/tui/views/tui_project_view.go ================================================ package views import ( "fmt" "strings" "github.com/rivo/tview" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/print" "github.com/alajmo/mani/core/tui/components" "github.com/alajmo/mani/core/tui/misc" ) type TProject struct { // UI Page *tview.Flex ContextView *tview.Flex ProjectTableView *components.TTable ProjectTreeView *components.TTree TagView *components.TList PathView *components.TList // Project Projects []dao.Project ProjectsFiltered []dao.Project ProjectsSelected map[string]bool projectFilterValue *string Headers []string ShowHeaders bool ProjectStyle string // Tags ProjectTags []string ProjectTagsFiltered []string ProjectTagsSelected map[string]bool projectTagFilterValue *string // Paths ProjectPaths []string ProjectPathsFiltered []string ProjectPathsSelected map[string]bool projectPathFilterValue *string // Misc Emitter *misc.EventEmitter } func CreateProjectsData( projects []dao.Project, projectTags []string, projectPaths []string, headers []string, prefixNumber int, showTitle bool, showHeaders bool, selectEnabled bool, showTags bool, showPaths bool, ) *TProject { p := &TProject{ Projects: projects, ProjectsFiltered: projects, ProjectsSelected: make(map[string]bool), projectFilterValue: new(string), ProjectTags: projectTags, ProjectTagsFiltered: projectTags, ProjectTagsSelected: make(map[string]bool), projectTagFilterValue: new(string), ProjectPaths: projectPaths, ProjectPathsFiltered: projectPaths, ProjectPathsSelected: make(map[string]bool), projectPathFilterValue: new(string), ProjectStyle: "project-table", ShowHeaders: showHeaders, Headers: headers, Emitter: misc.NewEventEmitter(), } for _, project := range p.Projects { p.ProjectsSelected[project.Name] = false } for _, tag := range p.ProjectTags { p.ProjectTagsSelected[tag] = false } for _, projectPath := range p.ProjectPaths { p.ProjectPathsSelected[projectPath] = false } title := "" if showTitle && prefixNumber > 0 { title = fmt.Sprintf("[%d] Projects (%d)", prefixNumber, len(projects)) prefixNumber += 1 } else if showTitle { title = fmt.Sprintf("Projects (%d)", len(projects)) } rows := p.getTableRows() projectTable := p.CreateProjectsTable(selectEnabled, title, headers, rows) p.ProjectTableView = projectTable paths := p.getTreeHierarchy() projectTree := p.CreateProjectsTree(selectEnabled, title, paths) p.ProjectTreeView = projectTree if showTags { tagTitle := "" if showTitle && prefixNumber > 0 { tagTitle = fmt.Sprintf("[%d] Tags (%d)", prefixNumber, len(projectTags)) prefixNumber += 1 } else { tagTitle = fmt.Sprintf("Tags (%d)", len(projectTags)) } tagsList := p.CreateProjectsTagsList(tagTitle) p.TagView = tagsList } if showPaths { pathTitle := "" if showTitle && prefixNumber > 0 { pathTitle = fmt.Sprintf("[%d] Paths (%d)", prefixNumber, len(projectPaths)) } else { pathTitle = fmt.Sprintf("Paths (%d)", len(projectPaths)) } pathsList := p.CreateProjectsPathsList(pathTitle) p.PathView = pathsList } // Events p.Emitter.Subscribe("remove_tag_path_filter", func(e misc.Event) { p.TagView.ClearFilter() p.PathView.ClearFilter() }) p.Emitter.Subscribe("remove_tag_path_selections", func(e misc.Event) { p.unselectAllTags() p.unselectAllPaths() }) p.Emitter.Subscribe("remove_project_filter", func(e misc.Event) { p.ProjectTableView.ClearFilter() p.ProjectTreeView.ClearFilter() }) p.Emitter.Subscribe("remove_project_selections", func(event misc.Event) { p.unselectAllProjects() }) p.Emitter.Subscribe("filter_projects", func(e misc.Event) { p.filterProjects() }) return p } func (p *TProject) CreateProjectsTable( selectEnabled bool, title string, headers []string, rows [][]string, ) *components.TTable { table := &components.TTable{ Title: title, ToggleEnabled: selectEnabled, ShowHeaders: p.ShowHeaders, FilterValue: p.projectFilterValue, } table.Create() table.Update(headers, rows) // Methods table.IsRowSelected = func(name string) bool { return p.ProjectsSelected[name] } table.ToggleSelectRow = func(name string) { p.toggleSelectProject(name) } table.SelectAll = func() { p.selectAllProjects() } table.UnselectAll = func() { p.unselectAllProjects() } table.FilterRows = func() { p.filterProjects() } table.DescribeRow = func(projectName string) { if projectName != "" { p.showProjectDescModal(projectName) } } table.EditRow = func(projectName string) { if projectName != "" { p.editProject(projectName) } } return table } func (p *TProject) CreateProjectsTree( selectEnabled bool, title string, paths []dao.TNode, ) *components.TTree { tree := &components.TTree{ Title: title, RootTitle: "", SelectEnabled: selectEnabled, FilterValue: p.projectFilterValue, } tree.Create() tree.UpdateProjects(paths) tree.IsNodeSelected = func(name string) bool { return p.ProjectsSelected[name] } tree.ToggleSelectNode = func(name string) { p.toggleSelectProject(name) } tree.SelectAll = func() { p.selectAllProjects() } tree.UnselectAll = func() { p.unselectAllProjects() } tree.FilterNodes = func() { p.filterProjects() } tree.DescribeNode = func(projectName string) { if projectName != "" { p.showProjectDescModal(projectName) } } tree.EditNode = func(projectName string) { if projectName != "" { p.editProject(projectName) } } return tree } func (p *TProject) CreateProjectsTagsList(title string) *components.TList { list := &components.TList{ Title: title, FilterValue: p.projectTagFilterValue, } list.Create() list.Update(p.ProjectTags) // Methods list.IsItemSelected = func(name string) bool { return p.ProjectTagsSelected[name] } list.ToggleSelectItem = func(i int, tag string) { p.ProjectTagsSelected[tag] = !p.ProjectTagsSelected[tag] list.SetItemSelect(i, tag) p.filterProjects() } list.SelectAll = func() { p.selectAllTags() p.filterProjects() } list.UnselectAll = func() { p.unselectAllTags() p.filterProjects() } list.FilterItems = func() { p.filterTags() } return list } func (p *TProject) CreateProjectsPathsList(title string) *components.TList { list := &components.TList{ Title: title, FilterValue: p.projectPathFilterValue, } list.Create() list.Update(p.ProjectPaths) // Methods list.IsItemSelected = func(name string) bool { return p.ProjectPathsSelected[name] } list.ToggleSelectItem = func(i int, tag string) { p.ProjectPathsSelected[tag] = !p.ProjectPathsSelected[tag] list.SetItemSelect(i, tag) p.filterProjects() } list.SelectAll = func() { p.selectAllPaths() p.filterProjects() } list.UnselectAll = func() { p.unselectAllPaths() p.filterProjects() } list.FilterItems = func() { p.filterPaths() } return list } func (p *TProject) getTableRows() [][]string { var rows = make([][]string, len(p.ProjectsFiltered)) for i, project := range p.ProjectsFiltered { rows[i] = make([]string, len(p.Headers)) for j, header := range p.Headers { rows[i][j] = project.GetValue(header, 0) } } return rows } func (p *TProject) getTreeHierarchy() []dao.TNode { var paths = []dao.TNode{} for _, p := range p.ProjectsFiltered { node := dao.TNode{Name: p.Name, Path: p.RelPath} paths = append(paths, node) } return paths } func (p *TProject) toggleSelectProject(name string) { p.ProjectsSelected[name] = !p.ProjectsSelected[name] p.ProjectTableView.ToggleSelectCurrentRow(name) p.ProjectTreeView.ToggleSelectCurrentNode(name) } func (p *TProject) filterProjects() { projectTags := []string{} for key, filtered := range p.ProjectTagsSelected { if filtered { projectTags = append(projectTags, key) } } projectPaths := []string{} for key, filtered := range p.ProjectPathsSelected { if filtered { projectPaths = append(projectPaths, key) } } if len(projectTags) > 0 || len(projectPaths) > 0 { projects, _ := misc.Config.FilterProjects(false, false, []string{}, projectPaths, projectTags, "") p.ProjectsFiltered = projects } else { p.ProjectsFiltered = p.Projects } var finalProjects []dao.Project for _, project := range p.ProjectsFiltered { if strings.Contains(project.Name, *p.projectFilterValue) { finalProjects = append(finalProjects, project) } } p.ProjectsFiltered = finalProjects // Table rows := p.getTableRows() p.ProjectTableView.Update(p.Headers, rows) p.ProjectTableView.Table.ScrollToBeginning() p.ProjectTableView.Table.Select(1, 0) // Tree paths := p.getTreeHierarchy() p.ProjectTreeView.UpdateProjects(paths) p.ProjectTreeView.UpdateProjectsStyle() p.ProjectTreeView.FocusFirst() } func (p *TProject) filterTags() { var finalTags []string for _, tag := range p.ProjectTags { if strings.Contains(tag, *p.projectTagFilterValue) { finalTags = append(finalTags, tag) } } p.ProjectTagsFiltered = finalTags p.TagView.Update(p.ProjectTagsFiltered) } func (p *TProject) filterPaths() { var finalPaths []string for _, path := range p.ProjectPaths { if strings.Contains(path, *p.projectPathFilterValue) { finalPaths = append(finalPaths, path) } } p.ProjectPathsFiltered = finalPaths p.PathView.Update(p.ProjectPathsFiltered) } func (p *TProject) selectAllProjects() { for _, project := range p.ProjectsFiltered { p.ProjectsSelected[project.Name] = true } p.ProjectTableView.UpdateRowStyle() p.ProjectTreeView.UpdateProjectsStyle() } func (p *TProject) selectAllTags() { for _, tag := range p.ProjectTagsFiltered { p.ProjectTagsSelected[tag] = true } p.TagView.Update(p.ProjectTagsFiltered) } func (p *TProject) selectAllPaths() { for _, path := range p.ProjectPathsFiltered { p.ProjectPathsSelected[path] = true } p.PathView.Update(p.ProjectPathsFiltered) } func (p *TProject) unselectAllProjects() { for _, project := range p.ProjectsFiltered { p.ProjectsSelected[project.Name] = false } p.ProjectTableView.UpdateRowStyle() p.ProjectTreeView.UpdateProjectsStyle() } func (p *TProject) unselectAllTags() { for _, tag := range p.ProjectTagsFiltered { p.ProjectTagsSelected[tag] = false } p.TagView.Update(p.ProjectTagsFiltered) } func (p *TProject) unselectAllPaths() { for _, path := range p.ProjectPathsFiltered { p.ProjectPathsSelected[path] = false } p.PathView.Update(p.ProjectPathsFiltered) } func (p *TProject) showProjectDescModal(name string) { project, err := misc.Config.GetProject(name) if err != nil { return } description := print.PrintProjectBlocks([]dao.Project{*project}, true, *misc.BlockTheme, print.TviewFormatter{}) descriptionNoColor := print.PrintProjectBlocks([]dao.Project{*project}, false, *misc.BlockTheme, print.TviewFormatter{}) components.OpenTextModal("project-description-modal", description, descriptionNoColor, project.Name) } func (p *TProject) editProject(projectName string) { misc.App.Suspend(func() { err := misc.Config.EditProject(projectName) if err != nil { return } }) } ================================================ FILE: core/tui/views/tui_shortcut_info.go ================================================ package views import ( "fmt" "strings" "github.com/alajmo/mani/core/tui/misc" "github.com/rivo/tview" ) type Shortcut struct { shortcut string label string } func getShortcutInfo(shortcuts []Shortcut) string { var formattedShortcuts []string for _, s := range shortcuts { value := fmt.Sprintf("[%s:%s:%s]%s[-:-:-] [%s:%s:%s]%s[-:-:-]", misc.STYLE_SHORTCUT_LABEL.Fg, misc.STYLE_SHORTCUT_LABEL.Bg, misc.STYLE_SHORTCUT_LABEL.AttrStr, s.label, misc.STYLE_SHORTCUT_TEXT.Fg, misc.STYLE_SHORTCUT_TEXT.Bg, misc.STYLE_SHORTCUT_TEXT.AttrStr, s.shortcut, ) formattedShortcuts = append(formattedShortcuts, value) } return strings.Join(formattedShortcuts, " ") } func CreateRunInfoVIew() *tview.TextView { shortcuts := []Shortcut{ {"Ctrl-r", "Run"}, {"Ctrl-s", "Toggle View"}, {"Ctrl-e", "Toggle Table/Tree"}, {"Ctrl-o", "Options"}, } text := getShortcutInfo(shortcuts) helpInfo := tview.NewTextView(). SetDynamicColors(true). SetText(text) helpInfo.SetTextAlign(tview.AlignRight) helpInfo.SetBorderPadding(0, 0, 0, 1) return helpInfo } func CreateExecInfoView() *tview.TextView { shortcuts := []Shortcut{ {"Ctrl-r", "Run"}, {"Ctrl-x", "Clear"}, {"Ctrl-s", "Toggle View"}, {"Ctrl-o", "Options"}, } text := getShortcutInfo(shortcuts) helpInfo := tview.NewTextView(). SetDynamicColors(true). SetText(text) helpInfo.SetTextAlign(tview.AlignRight) helpInfo.SetBorderPadding(0, 0, 0, 1) return helpInfo } func CreateProjectInfoView() *tview.TextView { shortcuts := []Shortcut{ {"Ctrl-e", "Toggle Table/Tree"}, } text := getShortcutInfo(shortcuts) helpInfo := tview.NewTextView(). SetDynamicColors(true). SetText(text) helpInfo.SetTextAlign(tview.AlignRight) helpInfo.SetBorderPadding(0, 0, 0, 1) return helpInfo } func CreateTaskInfoView() *tview.TextView { shortcuts := []Shortcut{ {"Ctrl-e", "Toggle Table/Tree"}, } text := getShortcutInfo(shortcuts) helpInfo := tview.NewTextView(). SetDynamicColors(true). SetText(text) helpInfo.SetTextAlign(tview.AlignRight) helpInfo.SetBorderPadding(0, 0, 0, 1) return helpInfo } ================================================ FILE: core/tui/views/tui_spec_view.go ================================================ package views import ( "os" "github.com/alajmo/mani/core/tui/components" "github.com/alajmo/mani/core/tui/misc" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) type TSpec struct { View *tview.Flex items []*tview.Box // Spec Output string ClearBeforeRun bool Parallel bool IgnoreErrors bool IgnoreNonExisting bool OmitEmptyRows bool OmitEmptyColumns bool } func CreateSpecView() *TSpec { defSpec, err := misc.Config.GetSpec("default") if err != nil { os.Exit(0) } spec := &TSpec{ Output: defSpec.Output, ClearBeforeRun: defSpec.ClearOutput, Parallel: defSpec.Parallel, IgnoreErrors: defSpec.IgnoreErrors, IgnoreNonExisting: defSpec.IgnoreNonExisting, OmitEmptyRows: defSpec.OmitEmptyRows, OmitEmptyColumns: defSpec.OmitEmptyColumns, } view := tview.NewFlex().SetDirection(tview.FlexRow) view.SetBorder(true).SetBorderPadding(1, 1, 1, 1). SetBorderColor(misc.STYLE_BORDER_FOCUS.Fg). SetBorderPadding(1, 1, 2, 2) spec.View = view // Output type outputType := &components.TToggleText{ Value: &spec.Output, Option1: "stream", Option2: "table", Label1: " Output stream ", Label2: " Output table ", Data1: "exec-stream", Data2: "exec-table", } outputType.Create() clearBeforeRun := spec.AddCheckbox("Clear Before Run", &spec.ClearBeforeRun) parallel := spec.AddCheckbox("Parallel", &spec.Parallel) ignoreErrors := spec.AddCheckbox("Ignore Errors", &spec.IgnoreErrors) ignoreNonExisting := spec.AddCheckbox("Ignore Non Existing", &spec.IgnoreNonExisting) omitEmptyRows := spec.AddCheckbox("Omit Empty Rows", &spec.OmitEmptyRows) omitEmptyColumns := spec.AddCheckbox("Omit Empty Columns", &spec.OmitEmptyColumns) // Add checkboxes view.AddItem(outputType.TextView, 1, 0, false) view.AddItem(clearBeforeRun, 1, 0, false) view.AddItem(parallel, 1, 0, false) view.AddItem(ignoreErrors, 1, 0, false) view.AddItem(ignoreNonExisting, 1, 0, false) view.AddItem(omitEmptyRows, 1, 0, false) view.AddItem(omitEmptyColumns, 1, 0, false) checkboxes := []*tview.Box{ outputType.TextView.Box, clearBeforeRun.Box, parallel.Box, ignoreErrors.Box, ignoreNonExisting.Box, omitEmptyRows.Box, omitEmptyColumns.Box, } // Input currentFocus := 0 view.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyDown: if currentFocus < (len(checkboxes) - 1) { currentFocus += 1 misc.App.SetFocus(checkboxes[currentFocus]) } return nil case tcell.KeyUp: if currentFocus > 0 { currentFocus -= 1 misc.App.SetFocus(checkboxes[currentFocus]) } return nil case tcell.KeyRune: switch event.Rune() { case 'g': // top currentFocus = 0 misc.App.SetFocus(checkboxes[currentFocus]) return nil case 'G': // bottom currentFocus = len(checkboxes) - 1 misc.App.SetFocus(checkboxes[currentFocus]) return nil case 'j': // down if currentFocus < (len(checkboxes) - 1) { currentFocus += 1 misc.App.SetFocus(checkboxes[currentFocus]) } return nil case 'k': // up if currentFocus > 0 { currentFocus -= 1 misc.App.SetFocus(checkboxes[currentFocus]) } return nil } } return event }) view.SetFocusFunc(func() { currentFocus = 0 misc.App.SetFocus(outputType.TextView) }) return spec } func (spec *TSpec) AddCheckbox(title string, checked *bool) *tview.Checkbox { onFocus := func() {} onBlur := func() {} checkbox := components.Checkbox(title, checked, onFocus, onBlur) spec.items = append(spec.items, checkbox.Box) return checkbox } ================================================ FILE: core/tui/views/tui_task_view.go ================================================ package views import ( "fmt" "strings" "github.com/alajmo/mani/core/dao" "github.com/alajmo/mani/core/print" "github.com/alajmo/mani/core/tui/components" "github.com/alajmo/mani/core/tui/misc" "github.com/rivo/tview" ) type TTask struct { // UI Page *tview.Flex TaskTableView *components.TTable TaskTreeView *components.TTree ContextView *tview.Flex // Data Tasks []dao.Task TasksFiltered []dao.Task TasksSelected map[string]bool Headers []string ShowHeaders bool TaskStyle string taskFilterValue *string // Misc Emitter *misc.EventEmitter } func CreateTasksData( tasks []dao.Task, headers []string, prefixNumber int, showTitle bool, showHeaders bool, selectEnabled bool, ) *TTask { t := &TTask{ Tasks: tasks, TasksFiltered: tasks, TasksSelected: make(map[string]bool), Headers: headers, ShowHeaders: showHeaders, TaskStyle: "task-table", taskFilterValue: new(string), Emitter: misc.NewEventEmitter(), } for _, task := range t.Tasks { t.TasksSelected[task.Name] = false } title := "" if showTitle && prefixNumber > 0 { title = fmt.Sprintf("[%d] Tasks (%d)", prefixNumber, len(tasks)) } else if showTitle { title = fmt.Sprintf("Tasks (%d)", len(tasks)) } rows := t.getTableRows() taskTable := t.CreateTasksTable(selectEnabled, title, headers, rows) t.TaskTableView = taskTable nodes := t.getTreeHierarchy() taskTree := t.CreateTasksTree(selectEnabled, title, nodes) t.TaskTreeView = taskTree // Events t.Emitter.Subscribe("remove_task_filter", func(e misc.Event) { t.TaskTableView.ClearFilter() t.TaskTreeView.ClearFilter() }) t.Emitter.Subscribe("remove_task_selections", func(event misc.Event) { t.unselectAllTasks() }) t.Emitter.Subscribe("filter_tasks", func(e misc.Event) { t.filterTasks() }) return t } func (t *TTask) CreateTasksTable( selectEnabled bool, title string, headers []string, rows [][]string, ) *components.TTable { table := &components.TTable{ Title: title, ToggleEnabled: selectEnabled, ShowHeaders: t.ShowHeaders, FilterValue: t.taskFilterValue, } table.Create() table.Update(headers, rows) // Methods table.IsRowSelected = func(name string) bool { return t.TasksSelected[name] } table.ToggleSelectRow = func(name string) { t.toggleSelectTask(name) } table.SelectAll = func() { t.selectAllTasks() } table.UnselectAll = func() { t.unselectAllTasks() } table.FilterRows = func() { t.filterTasks() } table.DescribeRow = func(taskName string) { if taskName != "" { t.showTaskDescModal(taskName) } } table.EditRow = func(taskName string) { if taskName != "" { t.editTask(taskName) } } return table } func (t *TTask) CreateTasksTree( selectEnabled bool, title string, nodes []components.TNode, ) *components.TTree { tree := &components.TTree{ Title: title, RootTitle: "", SelectEnabled: selectEnabled, FilterValue: t.taskFilterValue, } tree.Create() tree.UpdateTasks(nodes) tree.UpdateTasksStyle() tree.IsNodeSelected = func(name string) bool { return t.TasksSelected[name] } tree.ToggleSelectNode = func(name string) { t.toggleSelectTask(name) } tree.SelectAll = func() { t.selectAllTasks() } tree.UnselectAll = func() { t.unselectAllTasks() } tree.FilterNodes = func() { t.filterTasks() } tree.DescribeNode = func(taskName string) { if taskName != "" { t.showTaskDescModal(taskName) } } tree.EditNode = func(taskName string) { if taskName != "" { t.editTask(taskName) } } return tree } func (t *TTask) getTableRows() [][]string { var rows = make([][]string, len(t.TasksFiltered)) for i, task := range t.TasksFiltered { rows[i] = make([]string, len(t.Headers)) for j, header := range t.Headers { rows[i][j] = task.GetValue(header, 0) } } return rows } func (t *TTask) getTreeHierarchy() []components.TNode { var nodes = []components.TNode{} for _, task := range t.TasksFiltered { parentNode := &components.TNode{ DisplayName: task.Name, ID: task.Name, Type: "task", Children: &[]components.TNode{}, } // Sub-commands nodes = append(nodes, *parentNode) for _, subTask := range task.Commands { var node *components.TNode if subTask.TaskRef != "" { node = &components.TNode{ DisplayName: subTask.Name, ID: task.Name, Type: "task-ref", Children: &[]components.TNode{}, } } else { if subTask.Name == "" { subTask.Name = "cmd" } node = &components.TNode{ DisplayName: subTask.Name, ID: task.Name, Type: "command", Children: &[]components.TNode{}, } } *parentNode.Children = append(*parentNode.Children, *node) } } return nodes } func (t *TTask) toggleSelectTask(name string) { t.TasksSelected[name] = !t.TasksSelected[name] t.TaskTableView.ToggleSelectCurrentRow(name) t.TaskTreeView.ToggleSelectCurrentNode(name) } func (t *TTask) filterTasks() { var finalTasks []dao.Task for _, task := range t.Tasks { if strings.Contains(task.Name, *t.taskFilterValue) { finalTasks = append(finalTasks, task) } } t.TasksFiltered = finalTasks // Table rows := t.getTableRows() t.TaskTableView.Update(t.Headers, rows) t.TaskTableView.Table.ScrollToBeginning() t.TaskTableView.Table.Select(1, 0) // Tree taskTree := t.getTreeHierarchy() t.TaskTreeView.UpdateTasks(taskTree) t.TaskTreeView.UpdateTasksStyle() t.TaskTreeView.FocusFirst() } func (t *TTask) selectAllTasks() { for _, task := range t.TasksFiltered { t.TasksSelected[task.Name] = true } t.TaskTableView.UpdateRowStyle() t.TaskTreeView.UpdateTasksStyle() } func (t *TTask) unselectAllTasks() { for _, task := range t.TasksFiltered { t.TasksSelected[task.Name] = false } t.TaskTableView.UpdateRowStyle() t.TaskTreeView.UpdateTasksStyle() } func (t *TTask) showTaskDescModal(name string) { task, err := misc.Config.GetTask(name) if err != nil { return } description := print.PrintTaskBlock([]dao.Task{*task}, true, *misc.BlockTheme, print.TviewFormatter{}) descriptionNoColor := print.PrintTaskBlock([]dao.Task{*task}, false, *misc.BlockTheme, print.TviewFormatter{}) components.OpenTextModal("task-description-modal", description, descriptionNoColor, task.Name) } func (t *TTask) editTask(taskName string) { misc.App.Suspend(func() { err := misc.Config.EditTask(taskName) if err != nil { return } }) } ================================================ FILE: core/tui/watcher.go ================================================ package tui import ( "os" "path/filepath" "time" "github.com/fsnotify/fsnotify" ) func WatchFiles(app *App, files ...string) { if len(files) < 1 { return } w, err := fsnotify.NewWatcher() if err != nil { os.Exit(1) } go func() { defer func() { _ = w.Close() }() for _, p := range files { st, err := os.Lstat(p) if err != nil { os.Exit(1) } if st.IsDir() { os.Exit(1) } err = w.Add(filepath.Dir(p)) if err != nil { os.Exit(1) } } lastMod := make(map[string]time.Time) for { select { case _, ok := <-w.Errors: if !ok { return } os.Exit(1) case e, ok := <-w.Events: if !ok { return } for _, f := range files { if f == e.Name { stat, err := os.Stat(f) if err != nil { continue } currentMod := stat.ModTime() if lastMod[f] != currentMod { // TODO: For some reason, the reload is not working correctly, must be due to it being called in a goroutine // Sleeping resolves it somehow. time.Sleep(500 * time.Millisecond) app.Reload() lastMod[f] = currentMod } break } } } } }() } ================================================ FILE: core/utils.go ================================================ package core import ( "encoding/json" "errors" "fmt" "os" "os/exec" "os/user" "path/filepath" "regexp" "slices" "strings" ) const ANSI = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" var RE = regexp.MustCompile(ANSI) func Strip(str string) string { return RE.ReplaceAllString(str, "") } func Intersection(a []string, b []string) []string { var i []string for _, s := range a { if slices.Contains(b, s) { i = append(i, s) } } return i } func GetWdRemoteURL(path string) (string, error) { gitDir := filepath.Join(path, ".git") if _, err := os.Stat(gitDir); !os.IsNotExist(err) { return GetRemoteURL(path) } return "", nil } func GetRemoteURL(path string) (string, error) { cmd := exec.Command("git", "config", "--get", "remote.origin.url") cmd.Dir = path output, err := cmd.Output() if err != nil { return "", nil } return strings.TrimSuffix(string(output), "\n"), nil } // GetWorktreeList returns a map of worktrees (absolute path -> branch) for a git repo // Excludes the main worktree (the repo itself) func GetWorktreeList(repoPath string) (map[string]string, error) { cmd := exec.Command("git", "worktree", "list", "--porcelain") cmd.Dir = repoPath output, err := cmd.Output() if err != nil { return nil, err } worktrees := make(map[string]string) cleanRepoPath := filepath.Clean(repoPath) var currentPath string for line := range strings.SplitSeq(string(output), "\n") { if path, found := strings.CutPrefix(line, "worktree "); found { currentPath = filepath.Clean(path) } else if branch, found := strings.CutPrefix(line, "branch refs/heads/"); found { // Skip the main worktree (same as repoPath) if currentPath != cleanRepoPath { worktrees[currentPath] = branch } } // Detached HEAD worktrees (line == "detached") are intentionally // ignored — they have no branch to track. } return worktrees, nil } func FindFileInParentDirs(path string, files []string) (string, error) { for _, file := range files { pathToFile := filepath.Join(path, file) if _, err := os.Stat(pathToFile); err == nil { return pathToFile, nil } } parentDir := filepath.Dir(path) if parentDir == path { return "", &ConfigNotFound{files} } return FindFileInParentDirs(parentDir, files) } func GetRelativePath(configDir string, path string) (string, error) { relPath, err := filepath.Rel(configDir, path) return relPath, err } // Get the absolute path // Need to support following path types: // // lala/land // ./lala/land // ../lala/land // /lala/land // $HOME/lala/land // ~/lala/land // ~root/lala/land func GetAbsolutePath(configDir string, path string, name string) (string, error) { path = os.ExpandEnv(path) usr, err := user.Current() if err != nil { return "", err } homeDir := usr.HomeDir // TODO: Remove any .., make path absolute and then cut of configDir if path == "~" { path = homeDir } else if strings.HasPrefix(path, "~/") { path = filepath.Join(homeDir, path[2:]) } else if len(path) > 0 && filepath.IsAbs(path) { // TODO: Rewrite this } else if len(path) > 0 { path = filepath.Join(configDir, path) } else { path = filepath.Join(configDir, name) } return path, nil } // Get the absolute path // Need to support following path types: // // lala/land // ./lala/land // ../lala/land // /lala/land // $HOME/lala/land // ~/lala/land // ~root/lala/land func ResolveTildePath(path string) (string, error) { path = os.ExpandEnv(path) usr, err := user.Current() if err != nil { return "", err } homeDir := usr.HomeDir var p string if path == "~" { p = homeDir } else if strings.HasPrefix(path, "~/") { p = filepath.Join(homeDir, path[2:]) } else { p = path } return p, nil } // FormatShell returns the shell program and associated command flag func FormatShell(shell string) string { s := strings.Split(shell, " ") if len(s) > 1 { // User provides correct flag, bash -c, /bin/bash -c, /bin/sh -c return shell } else if strings.Contains(shell, "bash") { // bash, /bin/bash return shell + " -c" } else if strings.Contains(shell, "zsh") { // zsh, /bin/zsh return shell + " -c" } else if strings.Contains(shell, "sh") { // sh, /bin/sh return shell + " -c" } else if strings.Contains(shell, "node") { // node, /bin/node return shell + " -e" } else if strings.Contains(shell, "python") { // python, /bin/python return shell + " -c" } // TODO: Add fish and other shells return shell } // FormatShellString returns the shell program (bash,sh,.etc) along with the // command flag and subsequent commands // Example: // "bash", "-c echo hello world" func FormatShellString(shell string, command string) (string, []string) { shellProgram := FormatShell(shell) args := strings.SplitN(shellProgram, " ", 2) return args[0], append(args[1:], command) } // Used when creating pointers to literal. Useful when you want set/unset attributes. func Ptr[T any](t T) *T { return &t } func StringsToErrors(str []string) []error { errs := []error{} for _, s := range str { errs = append(errs, errors.New(s)) } return errs } func DebugPrint(data any) { s, _ := json.MarshalIndent(data, "", "\t") fmt.Println() fmt.Print(string(s)) fmt.Println() } ================================================ FILE: docs/changelog.md ================================================ # Changelog ## Unreleased ### Features - Added Git worktree support for projects [#119](https://github.com/alajmo/mani/issues/119) - Define worktrees in project config with `path` (required) and `branch` (optional, defaults to path basename) - `mani init` auto-discovers existing worktrees using `git worktree list` - `mani sync` creates worktrees defined in config - Worktrees can be inside or outside the parent project directory - Added `remove_orphaned_worktrees` config option to remove worktrees not in config - Added `--remove-orphaned-worktrees` / `-w` flag to `mani sync` ### Fixes - Fixed TUI to always show Tags/Paths panes even when empty - Fixed TUI search/filter label showing raw color tags when using default theme - Fixed `mani init` to only add root directory as project when inside a git repo ## 0.31.2 ### Fixes - Fixed `--tags-expr` flag to allow special characters in tag names (matching config file behavior) [#116](https://github.com/alajmo/mani/issues/116) - Fixed infinite recursion on Windows when finding mani config [#113](https://github.com/alajmo/mani/issues/113) [contributor: @aabiskar1] ## 0.31.1 ### Fixes - Fix panic when running task for a repository with a long name [#111](https://github.com/alajmo/mani/issues/111) ## 0.31.0 ### Features - Support fuzzy path selection #101 [contributor: @lucas-bremond] ## 0.30.1 ### Fixes - Reset task target when providing runtime flags [#92](https://github.com/alajmo/mani/issues/92) ## 0.30.0 ### Features - Added a sub-command to launch a TUI - Added `--forks` flag to limit parallel task execution [#74](https://github.com/alajmo/mani/issues/74) - Added `--target` specification from flags [#82](https://github.com/alajmo/mani/issues/82) - Added `--spec` specification from flags - Added `--ignore-sync-state` flag to `mani sync` to ignore `sync` status set projects [#83](https://github.com/alajmo/mani/issues/83) - Added `--tags-expr` flag for complex tag filtering expressions (e.g., (active || git) targets projects with either active or git tag) [#85](https://github.com/alajmo/mani/issues/85) - Added `--sync-gitignore` flag to opt out of `.gitignore` file modifications [#87](https://github.com/alajmo/mani/issues/87) - Added `tty` attribute to tasks which will replace the command and allow attaching to docker containers ### Fixes - Fixed `mani init` behavior when root directory contains `.git` [#78](https://github.com/alajmo/mani/issues/78) - Fixed `mani sync` execution when running `mani init` with remotes [#84](https://github.com/alajmo/mani/issues/84) - Fixed table column truncation when output exceeds terminal width ### Misc - Changed filtering tags/paths behavior to use intersection instead of union - Changed default shell from `sh` to `bash` - Improved multiple task execution by treating them as sub-commands for cleaner output - Renamed `--no-color` flag to `--color` - Changed output `text` to `stream` for all outputs (`flags`, `themes`, and `spec`) - Updated theme configuration system - Enhanced remote management: `mani` now removes git remotes if specified via global field `sync_remotes` config or flag `--sync-remotes` ## 0.25.0 ### Features - Add more box styles to table and tree output ### Misc - Update golang to 1.20.0 ## 0.24.0 ### Features - Add ability to create/sync remotes ## 0.23.0 ### Features - Add option `--ignore-non-existings` to ignore projects that don't exist - Add flag `--ignore-errors` to ignore errors ## 0.22.0 ### Features - Add filter options to sub-command sync [#52](https://github.com/alajmo/mani/pull/52) - Add check sub-command to validate mani config - Add option to disable spinner when running tasks [#54](https://github.com/alajmo/mani/pull/54) ### Fixes - Fix wrongly formatted YAML for init command ## 0.21.0 ### Features - Add path and url env to project clone command ## 0.20.1 ### Fixes - Fix evaluate env for MANI_CONFIG and MANI_USER_CONFIG - Fix parallel sync, limit to 20 projects at a time ### Changes - Use `mani --version` flag instead of `mani version` ## 0.20.0 A lot of refactoring and some new features added. There's also some breaking changes, notably how themes work. ### Features - Add option to skip sync on projects by setting `sync` property to `false` - Add flag to disable colors and respect NO_COLOR env variable when set - Add env variables MANI_CONFIG and MANI_USER_CONFIG that checks main config and user config - Add desc of tasks when auto-completing - Add man page generation - [BREAKING CHANGE]: Major theme overhaul, allow granular theme modification ### Fix - Don't automatically create the `$XDG_CONFIG_HOME/mani/config.yaml` file - Fix overriding spec data (parallel and omit-empty-columns) with flags - Fix when initializing mani with multiple repos having the same name [#30](https://github.com/alajmo/mani/issues/30), thanks to https://github.com/stessaris for finding the bug - Omit empty now checks all command outputs, and omits iff all of them are empty - Start spinner after 500 ms to avoid flickering when running commands which take less than 500 ms to execute ### Changes - [BREAKING CHANGE]: Remove no-headers flag - [BREAKING CHANGE]: Remove no-borders flag and enable it to be configurable via theme - [BREAKING CHANGE]: Removed default env variables that was set previously (MANI_PROJECT_PATH, .etc) - Remove some acceptable mani config filenames (notably, those that do not end in .yaml/.yml) - Update task and project describe - Improve error messages ### Internal - A lot of refactoring - Rework exec.Cmd - Remove aurora color library dependency and use the one provided by go-pretty ## v0.12.2 ### Fixes - Allow placing mani config inside one of directories of a mani project when syncing ## v0.12.0 ### Features - Add option to omit empty results - Add --vcs flag to mani init to choose vcs - Add default import from user config directory - [BREAKING CHANGE]: Add spec property to allow reusing common properties - Add target property to allow reusing common properties ### Fixes - Fix header bug in run print when task has both commands and cmd - Fix `mani edit` to run even if config file is malformed (wrong YAML syntax) ### Misc - [BREAKING CHANGE]: Move tree feature to list projects as a flag instead of it being a special sub-command - [BREAKING CHANGE]: Rename flag --all-projects to all - Remove legacy code related to Dirs entity - Change default value of --parallel flag to false when syncing - Allow omitting the -c when specifying shell for bash, zsh, sh, node and python ## v0.11.1 ### Fixes - Use syncmap to allow safe concurrent writes when running `mani sync` in parallel, previously there was a race condition that occurred when cloning many repos ### Features - Add `env` property to projects to enable project specific variables ## v0.10.0 ### Features - Add ability to import projects, tasks and themes - Possible to run tasks in parallel now per each project - Add sub-commands project/task to edit command to open editor at line corresponding to project/task - Add edit flag to describe/run sub-commands to open up editor - Sync projects in parallel by default and add flag serial to opt out - Add support for referencing commands in Commands property - Run commands in serial, if one fails, dont run other tasks - Add directory entity, similar to project, just without a url/clone property ### Misc - Add new acceptable filenames Manifile, Manifile.yaml, Manifile.yml - Don't create .gitignore if no projects with url exists on mani init/sync - List tags now shows associated dirs/projects - If user uses a cwd/tag/project/dir flag, then disable task targets - [BREAKING CHANGE:] A lot of syntax changes, use object notation instead of array list for projects, themes and tasks ## v0.6.1 ### Features - Add dirs filtering property to commands struct ### Fixes - Correct project path in gitignore file when running mani init ### Misc - Update help text for dirs flag ## v0.6.0 ### Features - New tree command that list contents of projects in a tree-like format - Add filtering on directory for tree/list/describe/run/exec cmd - Add global environment variables - Add describe flag to run cmd to suppress command information - Add sub-commands - Add possibility to run multiple commands from cli - Add default tags/projects/output to tasks - Add new table style that can be configured only from mani config - Add progress spinner for run/exec cmd ### Misc - [BREAKING CHANGE]: Renamed args in command block to env - [BREAKING CHANGE]: Renamed commands in root block to tasks - Environment variables now support shell execution - Rename flag format to output when listing ## v0.5.1 ### Fixes - Fix auto-complete for flag format in list command ## v0.5.0 ### Features - Add MANI environment variable that is cwd of the current context mani.yaml file - Add mani edit command which opens mani.yaml in preferred editor - Add describe cmd, display commands and projects in detail - Append default shell to commands - Add output formats table, markdown and html - Add no-borders, no-headers flags to print - Allow users to specify headers to be printed in list command - Sync creates gitignore file if not found - Use CLI spinner when syncing projects - Update info cmd to print git version ### Fixes - Output args at top for run commands instead of for each run - Output error message when running commands in non-mani directory that require mani config ### Misc - Refactor and make code more DRY - Refactor list and describe cmd to use sub-commands - With no projects to sync, output helpful message: "No projects to sync" - With all projects synced, output helpful message: "All projects synced" ## v0.4.0 ### Features - Allow users to set global and command level shell commands ## v0.3.0 ### Features - Add support for running from nested sub-directories - Add info sub-command that shows which configuration file is being used - Add flag to point to config file - Accept different config names (.mani, .mani.yaml, .mani.yml, mani.yaml, mani.yml) - Add new command exec to run arbitrary command - Add config flag - Add first argument to init should be path, if empty, current dir - Add completion for all commands bash - Update auto-discovery to equal true by default - Add option to filter list command on tags and projects - Add Nicer output on failed git sync - Add cwd flag to target current directory - Add comment section in .gitignore so users can modify the gitignore without mani overwriting all parts - Improved listing for projects/tags ### Fixes - Fix crashing on not found config file - Check possible, non-handled nil/err values - Don't add project to gitignore if doesn't have a url - Remove path if path is same as name - Fix gitignore sync, removing old entries - Fix broken init command - Fix so path accepts environment variables - Fix auto-complete when not in mani directory ### Misc - Update golang version and dependencies - Add integration tests ================================================ FILE: docs/commands.md ================================================ # Commands ## mani repositories manager and task runner ### Synopsis mani is a CLI tool that helps you manage multiple repositories. It's useful when you are working with microservices, multi-project systems, multiple libraries, or just a collection of repositories and want a central place for pulling all repositories and running commands across them. ### Options ``` --color enable color (default true) -c, --config string specify config -h, --help help for mani -u, --user-config string specify user config ``` ## run Run tasks ### Synopsis Run tasks. The tasks are specified in a mani.yaml file along with the projects you can target. ``` run ``` ### Examples ``` # Execute task for all projects mani run --all # Execute a task in parallel with a maximum of 8 concurrent processes mani run --projects --parallel --forks 8 # Execute task for a specific projects mani run --projects # Execute a task for projects with specific tags mani run --tags # Execute a task for projects matching specific paths mani run --paths # Execute a task for all projects matching a tag expression mani run --tags-expr 'active || git' # Execute a task with environment variables from shell mani run key=value ``` ### Options ``` -a, --all select all projects -k, --cwd select current working directory --describe display task information --dry-run display the task without execution -e, --edit edit task -f, --forks uint32 maximum number of concurrent processes (default 4) -h, --help help for run --ignore-errors continue execution despite errors --ignore-non-existing skip non-existing projects --omit-empty-columns hide empty columns in table output --omit-empty-rows hide empty rows in table output -o, --output string set output format [stream|table|markdown|html] --parallel execute tasks in parallel across projects -d, --paths strings select projects by path -p, --projects strings select projects by name -s, --silent hide progress output during task execution -J, --spec string set spec -t, --tags strings select projects by tag -E, --tags-expr string select projects by tags expression -T, --target string select projects by target name --theme string set theme --tty replace current process ``` ## exec Execute arbitrary commands ### Synopsis Execute arbitrary commands. Use single quotes around your command to prevent file globbing and environment variable expansion from occurring before the command is executed in each directory. ``` exec [flags] ``` ### Examples ``` # List files in all projects mani exec --all ls # List git files with markdown suffix in all projects mani exec --all 'git ls-files | grep -e ".md"' ``` ### Options ``` -a, --all target all projects -k, --cwd use current working directory --dry-run print commands without executing them -f, --forks uint32 maximum number of concurrent processes (default 4) -h, --help help for exec --ignore-errors ignore errors --ignore-non-existing ignore non-existing projects --omit-empty-columns omit empty columns in table output --omit-empty-rows omit empty rows in table output -o, --output string set output format [stream|table|markdown|html] --parallel run tasks in parallel across projects -d, --paths strings select projects by path -p, --projects strings select projects by name -s, --silent hide progress when running tasks -J, --spec string set spec -t, --tags strings select projects by tag -E, --tags-expr string select projects by tags expression -T, --target string target projects by target name --theme string set theme --tty replace current process ``` ## init Initialize a mani repository ### Synopsis Initialize a mani repository. Creates a mani.yaml configuration file in the current directory. When inside a git repository, it also creates/updates .gitignore. When auto-discovery is enabled, it finds Git repositories and their worktrees. ``` init [flags] ``` ### Examples ``` # Initialize with default settings (discovers repos and worktrees) mani init # Initialize without auto-discovering projects mani init --auto-discovery=false # Initialize without updating .gitignore mani init --sync-gitignore=false ``` ### Options ``` --auto-discovery automatically discover and add Git repositories and worktrees to mani.yaml (default true) -h, --help help for init -g, --sync-gitignore synchronize .gitignore file (default true) ``` ## sync Clone repositories and update .gitignore ### Synopsis Clone repositories and update .gitignore file. For repositories requiring authentication, disable parallel cloning to enter credentials for each repository individually. ``` sync [flags] ``` ### Examples ``` # Clone repositories one at a time mani sync # Clone repositories in parallel mani sync --parallel # Disable updating .gitignore file mani sync --sync-gitignore=false # Sync project remotes. This will modify the projects .git state mani sync --sync-remotes # Create worktrees defined in config (default behavior) mani sync # Remove worktrees not defined in config mani sync --remove-orphaned-worktrees # Clone repositories even if project sync field is set to false mani sync --ignore-sync-state # Display sync status mani sync --status ``` ### Options ``` -f, --forks uint32 maximum number of concurrent processes (default 4) -h, --help help for sync --ignore-sync-state sync project even if the project's sync field is set to false -p, --parallel clone projects in parallel -d, --paths strings clone projects by path -w, --remove-orphaned-worktrees remove git worktrees not in config -s, --status display status only -g, --sync-gitignore sync gitignore (default true) -r, --sync-remotes update git remote state -t, --tags strings clone projects by tags -E, --tags-expr string clone projects by tag expression ``` ## edit Open up mani config file ### Synopsis Open up mani config file in $EDITOR. ``` edit [flags] ``` ### Examples ``` # Edit current context mani edit ``` ### Options ``` -h, --help help for edit ``` ## edit project Edit mani project ### Synopsis Edit mani project in $EDITOR. ``` edit project [project] [flags] ``` ### Examples ``` # Edit projects mani edit project # Edit project mani edit project ``` ### Options ``` -h, --help help for project ``` ## edit task Edit mani task ### Synopsis Edit mani task in $EDITOR. ``` edit task [task] [flags] ``` ### Examples ``` # Edit tasks mani edit task # Edit task mani edit task ``` ### Options ``` -h, --help help for task ``` ## list projects List projects ### Synopsis List projects. ``` list projects [projects] [flags] ``` ### Examples ``` # List all projects mani list projects # List projects by name mani list projects # List projects by tags mani list projects --tags # List projects by paths mani list projects --paths # List projects matching a tag expression mani run --tags-expr ' || ' ``` ### Options ``` -a, --all select all projects (default true) -k, --cwd select current working directory --headers strings specify columns to display [project, path, relpath, description, url, tag, worktree] (default [project,tag,description]) -h, --help help for projects -d, --paths strings select projects by paths -t, --tags strings select projects by tags -E, --tags-expr string select projects by tags expression -T, --target string select projects by target name --tree display output in tree format ``` ### Options inherited from parent commands ``` -o, --output string set output format [table|markdown|html] (default "table") --theme string set theme (default "default") ``` ## list tags List tags ### Synopsis List tags. ``` list tags [tags] [flags] ``` ### Examples ``` # List all tags mani list tags ``` ### Options ``` --headers strings specify columns to display [project, tag] (default [tag,project]) -h, --help help for tags ``` ### Options inherited from parent commands ``` -o, --output string set output format [table|markdown|html] (default "table") --theme string set theme (default "default") ``` ## list tasks List tasks ### Synopsis List tasks. ``` list tasks [tasks] [flags] ``` ### Examples ``` # List all tasks mani list tasks # List tasks by name mani list task ``` ### Options ``` --headers strings specify columns to display [task, description, target, spec] (default [task,description]) -h, --help help for tasks ``` ### Options inherited from parent commands ``` -o, --output string set output format [table|markdown|html] (default "table") --theme string set theme (default "default") ``` ## describe projects Describe projects ### Synopsis Describe projects. ``` describe projects [projects] [flags] ``` ### Examples ``` # Describe all projects mani describe projects # Describe projects by name mani describe projects # Describe projects by tags mani describe projects --tags # Describe projects by paths mani describe projects --paths # Describe projects matching a tag expression mani run --tags-expr ' || ' ``` ### Options ``` -a, --all select all projects (default true) -k, --cwd select current working directory -e, --edit edit project -h, --help help for projects -d, --paths strings filter projects by paths -t, --tags strings filter projects by tags -E, --tags-expr string target projects by tags expression -T, --target string target projects by target name ``` ### Options inherited from parent commands ``` --theme string set theme (default "default") ``` ## describe tasks Describe tasks ### Synopsis Describe tasks. ``` describe tasks [tasks] [flags] ``` ### Examples ``` # Describe all tasks mani describe tasks # Describe task mani describe task ``` ### Options ``` -e, --edit edit task -h, --help help for tasks ``` ### Options inherited from parent commands ``` --theme string set theme (default "default") ``` ## tui TUI ### Synopsis Run TUI ``` tui [flags] ``` ### Examples ``` # Open tui mani tui ``` ### Options ``` -h, --help help for tui -r, --reload-on-change reload mani on config change --theme string set theme (default "default") ``` ## check Validate config ### Synopsis Validate config. ``` check [flags] ``` ### Examples ``` # Validate config mani check ``` ### Options ``` -h, --help help for check ``` ## gen Generate man page ``` gen ``` ### Options ``` -d, --dir string directory to save manpage to (default "./") -h, --help help for gen ``` ================================================ FILE: docs/config.md ================================================ # Config The mani.yaml config is based on the following concepts: - **projects** are directories, which may be git repositories, in which case they have an URL attribute - **tasks** are shell commands that you write and then run for selected **projects** - **specs** are configs that alter **task** execution and output - **targets** are configs that provide shorthand filtering of **projects** when executing **tasks** - **themes** are used to modify the output of `mani` commands - **env** are environment variables that can be defined globally, per project and per task **Specs**, **targets** and **themes** use a default object by default that the user can override to modify execution of mani commands. Check the [files](#files) and [environment](#environment) section to see how the config file is loaded. Below is a config file detailing all of the available options and their defaults. ```yaml # Import projects/tasks/env/specs/themes/targets from other configs import: - ./some-dir/mani.yaml # Shell used for commands # If you use any other program than bash, zsh, sh, node, and python # then you have to provide the command flag if you want the command-line string evaluted # For instance: bash -c shell: bash # If set to true, mani will override the URL of any existing remote # and remove remotes not found in the config sync_remotes: false # If set to true, mani will remove worktrees that exist on disk # but are not defined in the config remove_orphaned_worktrees: false # Determines whether the .gitignore should be updated when syncing projects sync_gitignore: true # When running the TUI, specifies whether it should reload when the mani config is changed reload_tui_on_change: false # List of Projects projects: # Project name [required] pinto: # Determines if the project should be synchronized during 'mani sync' sync: true # Project path relative to the config file # Defaults to project name if not specified path: frontend/pinto # Repository URL url: git@github.com:alajmo/pinto # Project description desc: A vim theme editor # Custom clone command # Defaults to "git clone URL PATH" clone: git clone git@github.com:alajmo/pinto --branch main # Branch to use as primary HEAD when cloning # Defaults to repository's primary HEAD branch: # When true, clones only the specified branch or primary HEAD single_branch: false # Project tags tags: [dev] # Remote repositories # Key is the remote name, value is the URL remotes: foo: https://github.com/bar # Git worktrees # path: Required, relative to project directory (or absolute) # branch: Optional, defaults to path basename # Auto-discovered by 'mani init', created by 'mani sync' worktrees: - path: hotfix # branch defaults to "hotfix" - path: feature-branch branch: feature/awesome - path: ../project-staging # worktree outside project dir branch: staging # Project-specific environment variables env: # Simple string value branch: main # Shell command substitution date: $(date -u +"%Y-%m-%dT%H:%M:%S%Z") # List of Specs specs: default: # Output format for task results # Options: stream, table, html, markdown output: stream # Enable parallel task execution parallel: false # Maximum number of concurrent tasks when running in parallel forks: 4 # When true, continues execution if a command fails in a multi-command task ignore_errors: false # When true, skips project entries in the config that don't exist # on the filesystem without throwing an error ignore_non_existing: false # Hide projects with no command output omit_empty_rows: false # Hide columns with no data omit_empty_columns: false # Clear screen before task execution (TUI only) clear_output: true # List of targets targets: default: # Select all projects all: false # Select project in current working directory cwd: false # Select projects by name projects: [] # Select projects by path paths: [] # Select projects by tag tags: [] # Select projects by tag expression tags_expr: '' # Environment variables available to all tasks env: # Simple string value AUTHOR: 'alajmo' # Shell command substitution DATE: $(date -u +"%Y-%m-%dT%H:%M:%S%Z") # List of tasks tasks: # Command name [required] simple-2: echo "hello world" # Command name [required] simple-1: cmd: | echo "hello world" desc: simple command 1 # Command name [required] advanced-command: # Task description desc: complex task # Task theme theme: default # Shell interpreter shell: bash # List of themes # Styling Options: # Fg (foreground color): Empty string (""), hex color, or named color from W3C standard # Bg (background color): Empty string (""), hex color, or named color from W3C standard # Format: Empty string (""), "lower", "title", "upper" # Attribute: Empty string (""), "bold", "italic", "underline" # Alignment: Empty string (""), "left", "center", "right" themes: # Theme name [required] default: # Stream Output Configuration stream: # Include project name prefix for each line prefix: true # Colors to alternate between for each project prefix prefix_colors: ['#d787ff', '#00af5f', '#d75f5f', '#5f87d7', '#00af87', '#5f00ff'] # Add a header before each project header: true # String value that appears before the project name in the header header_prefix: 'TASK' # Fill remaining spaces with a character after the prefix header_char: '*' # Table Output Configuration table: # Table style # Available options: ascii, light, bold, double, rounded style: ascii # Border options for table output border: around: false # Border around the table columns: true # Vertical border between columns header: true # Horizontal border between headers and rows rows: false # Horizontal border between rows header: fg: '#d787ff' attr: bold format: '' title_column: fg: '#5f87d7' attr: bold format: '' # Tree View Configuration tree: # Tree style # Available options: ascii, light, bold, double, rounded, bullet-square, bullet-circle, bullet-star style: light # Block Display Configuration block: key: fg: '#5f87d7' separator: fg: '#5f87d7' value: fg: value_true: fg: '#00af5f' value_false: fg: '#d75f5f' # TUI Configuration tui: default: fg: bg: attr: border: fg: border_focus: fg: '#d787ff' title: fg: bg: attr: align: center title_active: fg: '#000000' bg: '#d787ff' attr: align: center button: fg: bg: attr: format: button_active: fg: '#080808' bg: '#d787ff' attr: format: table_header: fg: '#d787ff' bg: attr: bold align: left format: item: fg: bg: attr: item_focused: fg: '#ffffff' bg: '#262626' attr: item_selected: fg: '#5f87d7' bg: attr: item_dir: fg: '#d787ff' bg: attr: item_ref: fg: '#d787ff' bg: attr: search_label: fg: '#d7d75f' bg: attr: bold search_text: fg: bg: attr: filter_label: fg: '#d7d75f' bg: attr: bold filter_text: fg: bg: attr: shortcut_label: fg: '#00af5f' bg: attr: shortcut_text: fg: bg: attr: ``` ## Files When running a command, `mani` will check the current directory and all parent directories for the following files: `mani.yaml`, `mani.yml`, `.mani.yaml`, `.mani.yml` . Additionally, it will import (if found) a config file from: - Linux: `$XDG_CONFIG_HOME/mani/config.yaml` or `$HOME/.config/mani/config.yaml` if `$XDG_CONFIG_HOME` is not set. - Darwin: `$HOME/Library/Application Support/mani/config.yaml` - Windows: `%AppData%\mani` Both the config and user config can be specified via flags or environments variables. ## Environment ```txt MANI_CONFIG Override config file path MANI_USER_CONFIG Override user config file path NO_COLOR If this env variable is set (regardless of value) then all colors will be disabled ``` ================================================ FILE: docs/contributing.md ================================================ # Contributing All contributions are welcome, be it [filing bugs](https://github.com/alajmo/mani/issues), feature suggestions or helping developing `mani`. ================================================ FILE: docs/development.md ================================================ # Development ## Build instructions ### Prerequisites - [go 1.25 or above](https://golang.org/doc/install) - [goreleaser](https://goreleaser.com/install/) ### Building ```bash # Build mani for your platform target make build # Build mani binaries and archives for all platforms using goreleaser make build-all # Generate Manpage make gen-man ``` ## Developing ```bash # Format code make gofmt # Manage dependencies (download/remove unused) make tidy # Lint code make lint # Build mani and get an interactive docker shell with completion make build-exec # Standing in _example directory you can run the following to debug faster (cd .. && make build-and-link && cd - && ../dist/mani run multi -p template-generator) ``` ## Releasing The following workflow is used for releasing a new `mani` version: 1. Create pull request with changes 2. Verify build works (especially windows build) - `make build` - `make build-all` 3. Pass all integration and unit tests locally - `make test-unit` - `make test-integration` 4. Update `config.man` and `config.md` if any config changes and generate manpage - `make gen-man` 5. Update `Makefile` and `CHANGELOG.md` with correct version, and add all changes to `CHANGELOG.md` 6. Squash-merge to main with `Release vx.y.z` and description of changes 7. Run `make release`, which will: - Create a git tag with release notes - Trigger a build in Github that builds cross-platform binaries and generates release notes of changes between current and previous tag ## Dependency Graph Create SVG dependency graphs using graphviz and [goda](https://github.com/loov/goda) ```bash goda graph "github.com/alajmo/mani/..." | dot -Tsvg -o res/graph.svg goda graph "github.com/alajmo/mani:all" | dot -Tsvg -o res/graph-full.svg ``` ================================================ FILE: docs/error-handling.md ================================================ # Error Handling ## Ignoring Task Errors If you wish to continue task execution even if an error is encountered, use the flag `--ignore-errors` or specify it in the task `spec`. - `ignore-errors` set to false ```bash $ mani run task-1 task-2 --all --ignore-errors=false Project | Task-1 | Task-2 ------------+---------------+-------- project-0 | | | exit status 1 | ------------+---------------+-------- project-1 | | | exit status 1 | ------------+---------------+-------- project-2 | | | exit status 1 | ``` - `ignore-errors` set to true ```bash Project | Task-1 | Task-2 ------------+---------------+-------- project-0 | | bar | exit status 1 | ------------+---------------+-------- project-1 | | bar | exit status 1 | ------------+---------------+-------- project-2 | | bar | exit status 1 | ``` ## Ignoring Non Existing Project - `--ignore-non-existing` set to false ```bash $ mani run task-1 --all error: path `/home/test/project-1` does not exist ``` - `ignore-unreachable` set to true ```bash $ mani run task-1 --all --ignore-non-existing Project | Task-1 ------------+-------- project-0 | hello ------------+-------- project-1 | ------------+-------- project-2 | hello ``` ================================================ FILE: docs/examples.md ================================================ # Examples This is an example of how to use `mani`. Save the following content to a file named `mani.yaml` and run `mani sync` to clone all repositories. If you already have your own repositories, you can omit the `projects` section. After setup, you can run any of the [commands](#commands) or check out [git-quick-stats.sh](https://git-quick-stats.sh/) for additional git statistics and run them via `mani` for multiple projects. ### Config ```yaml projects: example: path: . desc: A mani example pinto: path: frontend/pinto url: https://github.com/alajmo/pinto.git desc: A vim theme editor tags: [frontend, node] dashgrid: path: frontend/dashgrid url: https://github.com/alajmo/dashgrid.git desc: A highly customizable drag-and-drop grid tags: [frontend, lib, node] template-generator: url: https://github.com/alajmo/template-generator.git desc: A simple bash script used to manage boilerplates tags: [cli, bash] env: branch: dev specs: custom: output: table parallel: true targets: all: all: true themes: custom: table: border: around: true columns: true header: true rows: true tasks: git-status: desc: show working tree status spec: custom target: all cmd: git status -s git-last-commit-msg: desc: show last commit cmd: git log -1 --pretty=%B git-last-commit-date: desc: show last commit date cmd: | git log -1 --format="%cd (%cr)" -n 1 --date=format:"%d %b %y" \ | sed 's/ //' git-branch: desc: show current git branch cmd: git rev-parse --abbrev-ref HEAD npm-install: desc: run npm install in node repos target: tags: [node] cmd: npm install git-overview: desc: show branch, local and remote diffs, last commit and date spec: custom target: all theme: custom commands: - task: git-branch - task: git-last-commit-msg - task: git-last-commit-date ``` ## Commands ### List all Projects as Table or Tree: ```bash $ mani list projects Project | Tag | Description --------------------+---------------------+-------------------------------------------------- example | | A mani example pinto | frontend, node | A vim theme editor dashgrid | frontend, lib, node | A highly customizable drag-and-drop grid template-generator | cli, bash | A simple bash script used to manage boilerplates $ mani list projects --tree ┌─ frontend │ ├─ pinto │ └─ dashgrid └─ template-generator ``` ### Describe Task ```bash $ mani describe task git-overview Name: git-overview Description: show branch, local and remote diffs, last commit and date Theme: custom Target: All: true Cwd: false Projects: Paths: Tags: Spec: Output: table Parallel: true Forks: 4 IgnoreError: false OmitEmptyRows: false OmitEmptyColumns: false Commands: - git-branch: show current git branch - git-last-commit-msg: show last commit - git-last-commit-date: show last commit date ``` ### Run a Task Targeting Projects with Tag `node` and Output Table ```bash $ mani run npm-install --tags node TASK [npm-install: run npm install in node repos] ********************************* pinto | pinto | up to date, audited 911 packages in 928ms pinto | pinto | 71 packages are looking for funding pinto | run `npm fund` for details pinto | pinto | 15 vulnerabilities (9 moderate, 6 high) pinto | pinto | To address issues that do not require attention, run: pinto | npm audit fix pinto | pinto | To address all issues (including breaking changes), run: pinto | npm audit fix --force pinto | pinto | Run `npm audit` for details. TASK [npm-install: run npm install in node repos] ********************************* dashgrid | dashgrid | up to date, audited 960 packages in 1s dashgrid | dashgrid | 87 packages are looking for funding dashgrid | run `npm fund` for details dashgrid | dashgrid | 14 vulnerabilities (2 low, 2 moderate, 10 high) dashgrid | dashgrid | To address all issues possible (including breaking changes), run: dashgrid | npm audit fix --force dashgrid | dashgrid | Some issues need review, and may require choosing dashgrid | a different dependency. dashgrid | dashgrid | Run `npm audit` for details. ``` ### Run Custom Command for All Projects ```bash $ mani exec --all --output table --parallel 'find . -type f | wc -l' Project | Output --------------------+-------- example | 31016 pinto | 14444 dashgrid | 16527 template-generator | 42 ``` ================================================ FILE: docs/filtering-projects.md ================================================ # Filtering Projects Projects can be filtered when managing projects (sync, list, describe) or running tasks. Filters can be specified through CLI flags or target configurations. The filtering is inclusive, meaning projects must satisfy all specified filters to be included in the results. Available options: - **cwd**: include only the project under the current working directory, ignoring all other filters - **all**: include all projects - **projects**: Filter by project names - **paths**: Filter by project paths - **tags**: Filter by project tags - **tags_expr**: Filter using tag logic expressions - **target**: Filter using target For `mani sync/list/describe`: - No filters: Targets all projects - Multiple filters: Select intersection of `projects/paths/tags/tags_expr/target` filters For `mani run/exec` the precedence is: 1. Runtime flags (highest priority) 2. Target flag configuration (`--target`) 3. Task's default target data (lowest priority) The default target is named `default` and can be overridden by defining a target named `default` in the config. This only applies for sub-commands `run` and `exec`. ## Tags Expression Tag expressions allow filtering projects using boolean operations on their tags. The expression is evaluated for each project's tags to determine if the project should be included. Operators (in precedence order): - (): Parentheses for grouping - !: NOT operator (logical negation) - &&: AND operator (logical conjunction) - ||: OR operator (logical disjunction) Tags in expressions can contain any characters except: - Whitespace (spaces, tabs, newlines) - Reserved characters: `(`, `)`, `!`, `&`, `|` This means tags can include letters, numbers, hyphens, underscores, dots, and other special characters like `@`, `#`, `$`, etc. For example: `my-tag`, `v1.0`, `frontend_v2`, `@scope/package`. ### Example For example, the expression: - (main && (dev || prod)) && !test requires the projects to pass these conditions: - Must have "main" tag - Must have either "dev" OR "prod" tag - Must NOT have "test" tag ================================================ FILE: docs/installation.md ================================================ # Installation `mani` is available on Linux and Mac, with partial support for Windows. * Binaries are available on the [release](https://github.com/alajmo/mani/releases) page * via cURL (Linux & macOS) ```bash curl -sfL https://raw.githubusercontent.com/alajmo/mani/main/install.sh | sh ``` * via Homebrew ```bash brew tap alajmo/mani brew install mani ``` * via MacPorts ```sh sudo port install mani ``` * via Arch ```sh pacman -S mani ``` * via Nix ```sh nix-env -iA nixos.mani ``` * via Go ```bash go get -u github.com/alajmo/mani ``` ## Building From Source 1. Clone the repo 2. Build and run the executable ```bash make build && ./dist/mani ``` ================================================ FILE: docs/introduction.md ================================================ --- slug: / --- # Introduction `mani` is a CLI tool that helps you manage multiple repositories. It's useful when you are working with microservices, multi-project systems, multiple libraries, or just a collection of repositories and want a central place for pulling all repositories and running commands across them. `mani` has many features: - Declarative configuration - Clone multiple repositories with a single command - Run custom or ad-hoc commands across multiple repositories - Built-in TUI - Flexible filtering - Customizable theme - Auto-completion support - Portable, no dependencies ## Demo ![demo](/img/demo.gif) ## Example You specify repositories and commands in a configuration file: ```yaml title="mani.yaml" projects: pinto: url: https://github.com/alajmo/pinto.git desc: A vim theme editor tags: [frontend, node] template-generator: url: https://github.com/alajmo/template-generator.git desc: A simple bash script used to manage boilerplates tags: [cli, bash] env: branch: dev tasks: git-status: desc: Show working tree status cmd: git status git-create: desc: Create branch spec: output: text env: branch: main cmd: git checkout -b $branch ``` Run `mani sync` to clone the repositories: ```bash $ mani sync ✓ pinto ✓ dashgrid All projects synced ``` Then run commands across all or a subset of the repositories: ```bash # Target repositories that have the tag 'node' $ mani run git-status --tags node ┌──────────┬─────────────────────────────────────────────────┐ │ Project │ git-status │ ├──────────┼─────────────────────────────────────────────────┤ │ pinto │ On branch master │ │ │ Your branch is up to date with 'origin/master'. │ │ │ │ │ │ nothing to commit, working tree clean │ └──────────┴─────────────────────────────────────────────────┘ # Target project 'pinto' $ mani run git-create --projects pinto branch=dev [pinto] TASK [git-create: create branch] ******************* Switched to a new branch 'dev' ``` ================================================ FILE: docs/man-pages.md ================================================ # Man Page Man page generation is available via: ```bash $ mani gen Created mani.1 # Or specify a different directory $ mani gen --dir /usr/local/share/man/man1/ Created /usr/local/share/man/man1/mani.1 ``` ================================================ FILE: docs/output.md ================================================ # Output Format `mani` supports different output formats for tasks. By default it will use `stream` output, but it's possible to change this via the `--output` flag or specify it in the task `spec`. The following output formats are available: - **stream** (default) ``` TASK (1/2) [hello] *********** test | world test | bar TASK (2/2) [foo] *********** test | world test | bar ``` - **table** ``` Project │ Hello │ Foo ──────────┼───────┼─────── test │ world │ bar ──────────┼───────┼─────── test-2 │ world │ bar ``` - **html** ```html
project hello foo
test world bar
test-2 world bar
``` - **markdown** ```markdown | project | hello | foo | | ------- | ----- | --- | | test | world | bar | | test-2 | world | bar | ``` ## Omit Empty Table Rows and Columns Omit empty outputs using `--omit-empty-rows` and `--omit-empty-columns` flags or task spec. Works for tables, markdown and html formats. See below for an example: ```bash $ mani run cmd-1 cmd-2 -s project-1,project-2 -o table Project │ Cmd-1 │ Cmd-2 ──────────┼───────┼─────── test │ │ ──────────┼───────┼─────── test-2 │ world │ $ mani run test -s project-1,project-2 -o table --omit-empty-rows --omit-empty-columns TASKS ******************************* Project | Cmd-1 ---------+-------- test-2 | world ``` ================================================ FILE: docs/project-background.md ================================================ # Project Background This document contains a little bit of everything: - Background to `mani` and core design decisions used to develop `mani` - Comparisons with alternatives - Roadmap ## Background `mani` came about because I needed a CLI tool to manage multiple repositories. So, the premise is, you have a bunch of repositories and want the following: 1. a central place for your repositories, containing name, URL, and a small description of the repository 2. ability to clone all repositories in 1 command 3. ability to run ad-hoc and custom commands (perhaps `git status` to see working tree status) on 1, a subset, or all of the repositories 4. ability to get an overview of 1, a subset, or all of the repositories and commands Now, there's plenty of CLI tools for running cloning multiple repositories, running commands over them, see [similar software](#similar-software), and while I've taken a lot of inspiration from them, there's some core design decision that led me to create `mani`, instead of forking or contributing to an existing solution. ## Design ### Config A lot of the alternatives to `mani` treat the config file (either using a custom format or JSON) as a state file that is interacted with via their executable. So the way it works is, you would add a repository to the config file via `sometool add git@github.com/random/xyz`, and then to remove the repository, you'd have to open the config file and remove it manually, taking care to also update the `.gitignore` file. I think it's a missed opportunity to not let users edit the config file manually for the following reasons: 1. The user can add additional metadata about the repositories 2. The user can order the repositories to their liking to provide a better overview of the repositories, rather than using an alphabetical or random order 3. It's seldom that you add new repositories, so it's not something that should be optimized for That's why in `mani` you need to edit the config file to add or delete a repository. The exception is when you're setting up `mani` for the first time, then you want it to scan for existing repositories. As a bonus, it also updates your `.gitignore` file with the updated list of repositories. ### Commands Another missed opportunity is not to have built-in support for commands. For instance, [meta](https://github.com/mateodelnorte/meta), delegates this to 3rd party tools like `make`, which makes you lose out on a few benefits: 1. Fewer tools for developers to learn (albeit `make` is something many are already familiar with) 2. Fewer files to keep track of (1 file instead of 2) 3. Better auto-completion and command discovery Note, you can still use `make` or regular script files, just call them from the `mani.yaml` config. So what config format is best suited for this purpose? In my opinion, YAML is a suitable candidate. While it has its issues, I think its purpose as a human-readable config/state file works well. It has all the primitives you'd need in a config language, simple key/value entries, dictionaries, and lists, as well as supporting comments (something which JSON doesn't). We could create a custom format, but then users would have to learn that syntax, so in this case, YAML has a major advantage, almost all software developers are familiar with it. ### Filtering When we run commands, we need a way to target specific repositories. To make it as flexible as possible, there are three ways to do it in `mani`: 1. **Tag filtering**: target repositories which have a tag, for instance, add a tag `python` to all `python` repositories, then it's as simple as `mani run status -t python` 2. **Directory filtering**: target repositories by which directory they belong to, `mani run status -d frontend`, will target all repositories that are in the `frontend` directory 3. **Project name filtering**: target repositories by their name, `mani run status -p dashgrid`, will target the project `dashgrid` ### General UX These various features make using `mani` feel more effortless: - Automatically updating .gitignore when updating the config file - Rich auto-completion - Edit the `mani` config file via the `mani edit` command, which opens up the config file in your preferred editor - Most organizations/people use git, but not everyone uses it or even uses it in the same way, so it's important to provide escape hatches, where people can provide their own VCS and customize commands to clone repositories - Single binary (most alternatives require Python or Node.js runtime) - Pretty output when running commands or listing repositories/commands - Default tags/dirs/name filtering for commands - Export output as HTML/Markdown from list/run/exec commands ## Similar Software - [gita](https://github.com/nosarthur/gita) - [gr](https://github.com/mixu/gr) - [meta](https://github.com/mateodelnorte/meta) - [mu-repo](https://github.com/fabioz/mu-repo) - [myrepos](https://myrepos.branchable.com/) - [repo](https://source.android.com/setup/develop/repo) - [vcstool](https://github.com/dirk-thomas/vcstool) ================================================ FILE: docs/roadmap.md ================================================ # Roadmap `mani` is under active development. Before **v1.0.0**, I want to finish the following tasks: - [ ] Bring changes from `sake` - Refactor import logic and support recursive nesting of tasks - Add new table format output (tasks in 1st column, output in 2nd, one table per project) - [ ] Allow user to edit mani config from command line or TUI ================================================ FILE: docs/shell-completion.mdx ================================================ # Shell Completion Shell completion is available for `bash`, `zsh`, `fish` and `powershell`. import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; ```bash mani completion bash ``` ```bash mani completion zsh ``` ```bash mani completion fish ``` ```bash mani completion powershell ``` ================================================ FILE: docs/usage.md ================================================ # Usage ## Initialize Mani Run the following command inside a directory containing your `git` repositories: ```bash mani init ``` This will generate: - `mani.yaml`: Contains projects and custom tasks. Any subdirectory that has a `.git` directory will be included (add the flag `--auto-discovery=false` to turn off this feature) - `.gitignore`: (only when inside a git repo) Includes the projects specified in `mani.yaml` file. To opt out, use `mani init --sync-gitignore=false`. It can be helpful to initialize the `mani` repository as a git repository so that anyone can easily download the `mani` repository and run `mani sync` to clone all repositories and get the same project setup as you. ## Example Commands ```bash # List all projects mani list projects # Run git status across all projects mani exec --all git status # Run git status across all projects in parallel with output in table format mani exec --all --parallel --output table git status ``` Next up: - [Some more examples](/examples) - [Familiarize yourself with the mani.yaml config](/config) - [Checkout mani commands](/commands) ================================================ FILE: docs/variables.md ================================================ # Variables `mani` supports setting variables for both projects and tasks. Variables can be either strings or commands (encapsulated by $()) that will be evaluated once for each task. ```yaml projects: pinto: path: pinto url: https://github.com/alajmo/pinto.git env: foo: bar tasks: ping: cmd: | echo "$msg" echo "$date" echo "$foo" env: msg: text date: $(date) ``` ## Pass Variables from CLI To pass variables from the command line, provide them as arguments. For example: ```bash mani run msg option=123 ``` The environment variable option will then be available for use within the task. ================================================ FILE: examples/.gitignore ================================================ # mani # template-generator frontend/pinto # mani # ================================================ FILE: examples/README.md ================================================ # Examples This is an example of how you can use `mani`. Simply save the following content to a file named `mani.yaml` and then run `mani sync` to clone all the repositories. If you already have your own repositories, just omit the `projects` section. You can then run some of the [commands](#commands) or checkout [git-quick-stats.sh](https://git-quick-stats.sh/) for additional git statistics and run them via `mani` for multiple projects. ### Config ```yaml projects: example: path: . desc: A mani example pinto: path: frontend/pinto url: https://github.com/alajmo/pinto.git desc: A vim theme editor tags: [frontend, node] template-generator: url: https://github.com/alajmo/template-generator.git desc: A simple bash script used to manage boilerplates tags: [cli, bash] env: branch: dev specs: custom: output: table parallel: true forks: 8 targets: all: all: true themes: custom: table: border: around: true header: true columns: true rows: true tasks: git-status: desc: Show working tree status spec: custom target: all cmd: git status -s git-last-commit-msg: desc: Show last commit cmd: git log -1 --pretty=%B git-last-commit-date: desc: Show last commit date cmd: | git log -1 --format="%cd (%cr)" -n 1 --date=format:"%d %b %y" \ | sed 's/ //' git-branch: desc: Show current git branch cmd: git rev-parse --abbrev-ref HEAD npm-install: desc: Run npm install in node repos target: tags: [node] cmd: npm install git-overview: desc: Show branch, local and remote diffs, last commit and date spec: custom target: all theme: custom commands: - task: git-branch - task: git-last-commit-msg - task: git-last-commit-date ``` ## Commands ### List All Projects as Table or Tree:: ```bash $ mani list projects Project | Tag | Description --------------------+---------------------+-------------------------------------------------- example | | A mani example pinto | frontend, node | A vim theme editor dashgrid | frontend, lib, node | A highly customizable drag-and-drop grid template-generator | cli, bash | A simple bash script used to manage boilerplates $ mani list projects --tree ┌─ frontend │ ├─ pinto │ └─ dashgrid └─ template-generator ``` ### Describe Task ```bash $ mani describe task git-overview Name: git-overview Description: show branch, local and remote diffs, last commit and date Theme: custom Target: All: true Cwd: false Projects: Paths: Tags: Spec: Output: table Parallel: true Forks: 4 IgnoreErrors: false IgnoreNonExisting: false OmitEmptyRows: false OmitEmptyColumns: false Commands: - git-branch: show current git branch - git-last-commit-msg: show last commit - git-last-commit-date: show last commit date ``` ### Run a Task Targeting Projects with Tag `node` and Output Table: ```bash $ mani run npm-install --tags node TASK [npm-install: run npm install in node repos] ********************************* pinto | pinto | up to date, audited 911 packages in 928ms pinto | pinto | 71 packages are looking for funding pinto | run `npm fund` for details pinto | pinto | 15 vulnerabilities (9 moderate, 6 high) pinto | pinto | To address issues that do not require attention, run: pinto | npm audit fix pinto | pinto | To address all issues (including breaking changes), run: pinto | npm audit fix --force pinto | pinto | Run `npm audit` for details. TASK [npm-install: run npm install in node repos] ********************************* dashgrid | dashgrid | up to date, audited 960 packages in 1s dashgrid | dashgrid | 87 packages are looking for funding dashgrid | run `npm fund` for details dashgrid | dashgrid | 14 vulnerabilities (2 low, 2 moderate, 10 high) dashgrid | dashgrid | To address all issues possible (including breaking changes), run: dashgrid | npm audit fix --force dashgrid | dashgrid | Some issues need review, and may require choosing dashgrid | a different dependency. dashgrid | dashgrid | Run `npm audit` for details. ``` ### Run Custom Command for All Projects ```bash $ mani exec --all --output table --parallel 'find . -type f | wc -l' Project | Output --------------------+-------- example | 31016 pinto | 14444 dashgrid | 16527 template-generator | 42 ``` ================================================ FILE: examples/mani.yaml ================================================ projects: example: path: . desc: A mani example pinto: path: frontend/pinto url: https://github.com/alajmo/pinto.git desc: A vim theme editor tags: [frontend, node] template-generator: url: https://github.com/alajmo/template-generator.git desc: A simple bash script used to manage boilerplates tags: [cli, bash] env: branch: dev specs: custom: output: table parallel: true targets: all: all: true themes: custom: table: border: around: true columns: true header: true rows: true tasks: git-status: desc: show working tree status spec: custom target: all cmd: git status git-last-commit-msg: desc: show last commit cmd: git log -1 --pretty=%B git-last-commit-date: desc: show last commit date cmd: | git log -1 --format="%cd (%cr)" -n 1 --date=format:"%d %b %y" \ | sed 's/ //' git-branch: desc: show current git branch cmd: git rev-parse --abbrev-ref HEAD npm-install: desc: run npm install in node repos target: tags: [node] cmd: npm install git-overview: desc: show branch, local and remote diffs, last commit and date spec: custom target: all theme: custom commands: - task: git-branch - task: git-last-commit-msg - task: git-last-commit-date ================================================ FILE: go.mod ================================================ module github.com/alajmo/mani go 1.25.5 require ( github.com/fsnotify/fsnotify v1.9.0 github.com/gdamore/tcell/v2 v2.13.8 github.com/gookit/color v1.6.0 github.com/jedib0t/go-pretty/v6 v6.7.8 github.com/jinzhu/copier v0.4.0 github.com/kr/pretty v0.2.1 github.com/otiai10/copy v1.6.0 github.com/rivo/tview v0.42.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/theckman/yacspin v0.13.12 golang.org/x/sys v0.41.0 golang.org/x/term v0.40.0 golang.org/x/text v0.34.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/clipperhouse/uax29/v2 v2.6.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/fatih/color v1.18.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) ================================================ FILE: go.sum ================================================ github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o= github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/otiai10/copy v1.6.0 h1:IinKAryFFuPONZ7cm6T6E2QX/vcJwSnlaA5lfoaXIiQ= github.com/otiai10/copy v1.6.0/go.mod h1:XWfuS3CrI0R6IE0FbgHsEazaXO8G0LpMp9o8tos0x4E= github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= github.com/otiai10/mint v1.3.2 h1:VYWnrP5fXmz1MXvjuUvcBrXSjGE6xjON+axB/UrpO3E= github.com/otiai10/mint v1.3.2/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: install.sh ================================================ #!/bin/sh # Credit to https://github.com/ducaale/xh for this install script. set -e if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "x86_64" ]; then target="darwin_amd64" elif [ "$(uname -s)" = "Linux" ] && [ "$(uname -m)" = "x86_64" ]; then target="linux_amd64" elif [ "$(uname -s)" = "Linux" ] && ( uname -m | grep -q -e '^arm' -e '^aarch' ); then target="linux_arm64" else echo "Unsupported OS or architecture" exit 1 fi fetch() { if which curl > /dev/null; then if [ "$#" -eq 2 ]; then curl -L -o "$1" "$2"; else curl -sSL "$1"; fi elif which wget > /dev/null; then if [ "$#" -eq 2 ]; then wget -O "$1" "$2"; else wget -nv -O - "$1"; fi else echo "Can't find curl or wget, can't download package" exit 1 fi } echo "Detected target: $target" url=$( fetch https://api.github.com/repos/alajmo/mani/releases/latest | tac | tac | grep -wo -m1 "https://.*$target.tar.gz" || true ) if ! test "$url"; then echo "Could not find release info" exit 1 fi echo "Downloading mani..." temp_dir=$(mktemp -dt mani.XXXXXX) trap 'rm -rf "$temp_dir"' EXIT INT TERM cd "$temp_dir" if ! fetch mani.tar.gz "$url"; then echo "Could not download tarball" exit 1 fi user_bin="$HOME/.local/bin" case $PATH in *:"$user_bin":* | "$user_bin":* | *:"$user_bin") default_bin=$user_bin ;; *) default_bin='/usr/local/bin' ;; esac printf "Install location [default: %s]: " "$default_bin" read -r bindir < /dev/tty bindir=${bindir:-$default_bin} while ! test -d "$bindir"; do echo "Directory $bindir does not exist" printf "Install location [default: %s]: " "$default_bin" read -r bindir < /dev/tty bindir=${bindir:-$default_bin} done tar xzf mani.tar.gz if test -w "$bindir"; then mv mani "$bindir/" else sudo mv mani "$bindir/" fi $bindir/mani --version ================================================ FILE: main.go ================================================ package main import ( "github.com/alajmo/mani/cmd" ) func main() { cmd.Execute() } ================================================ FILE: res/demo.md ================================================ # Demo To generate a `demo.gif` use [vhs](https://github.com/charmbracelet/vhs). Requires: - ffmpeg - ttyd ``` # Stand in mani/res vhs demo.gif ``` To update `demo.gif`, modify `demo.vhs`. ================================================ FILE: res/demo.vhs ================================================ Output demo.gif Set FontSize 28 Set Width 1920 Set Height 1080 Sleep 500ms Type "mani sync" Enter Sleep 2000ms Type "mani tui" Sleep 2000ms Enter # Select task Sleep 1000ms Type "G" Sleep 2000ms Space # Filter tags Type "3" Sleep 2000ms Space # Select project Type "2" Sleep 1000ms Type "a" # Run Sleep 2000ms Ctrl+R Sleep 4s ================================================ FILE: res/mani.yaml ================================================ reload_tui_on_change: true sync_remotes: true projects: projects: path: . mani: path: go/mani url: https://github.com/alajmo/mani.git remotes: foo: https://github.com/alajmo/mani.git bar: https://github.com/alajmo/mani.git tags: [git, mani] sake: path: go/sake url: https://github.com/alajmo/sake.git tags: [git, sake] tasks: current-branch: desc: print current branch cmd: git branch num-branches: desc: 'print # branches' cmd: git branch | wc -l num-commits: desc: 'print # commits' cmd: git rev-list --all --count num-authors: desc: 'print # authors' cmd: git shortlog -s -n --all --no-merges | wc -l print-overview: desc: 'show # commits, # branches, # authors, last commit date' commands: - task: current-branch - task: num-branches - task: num-commits - task: num-authors ================================================ FILE: scripts/release.sh ================================================ #!/bin/bash set -euo pipefail # Get latest version changes only sed '0,/## v/d;/## v/Q' docs/changelog.md | tail -n +2 | head -n-1 > release-changelog.md ================================================ FILE: test/README.md ================================================ # Test `mani` currently only has integration tests, which require `docker` to run. This is because `mani` mainly interacts with the filesystem, and whilst there are ways to mock the filesystem, it's simply easier (and fast enough) to spin up a `docker` container and do the work there. The tests are based on something called "golden files", which are the expected output of the tests. It serves the benefit of working as documentation as well, since it becomes easy to see the desired output of the different `mani` commands. There's some helpful scripts in the `scripts` directory that can be used to test and debug `mani`. These scripts should be run from the project directory. The Docker test container includes a script `git` which only creates the project directories, it doesn't clone the actual repositories. ## Directory Structure ```sh . ├── fixtures # files needed for testing purposes ├── images # docker images used for testing and development ├── integration # integration tests and golden files ├── scripts # scripts for development and testing └── tmp # docker mounted volume that you can preview test output ``` ## Prerequisites - [docker](https://docs.docker.com/get-docker/) - [golangci-lint](https://golangci-lint.run) ## Testing & Development Checkout the below commands and the [Makefile](../Makefile) to test/debug `mani`. ```sh # Run tests ./test/scripts/test # Run specific tests, print stdout and build mani ./test/scripts/test --debug --build --run TestInitCmd # Update Golden Files ./test/scripts/test -u # Start an interactive shell inside docker ./test/scripts/exec --shell bash|zsh|fish # Debug completion mani __complete list tags --projects "" # Stand in _example directory (cd ../ && make build-and-link && cd - && mani run status --cwd) ``` ================================================ FILE: test/fixtures/mani-advanced/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/fixtures/mani-advanced/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/fixtures/mani-empty/mani.yaml ================================================ ================================================ FILE: test/fixtures/mani-no-tasks/mani.yaml ================================================ projects: example: path: . tap-report: path: frontend/tap-report url: https://github.com/alajmo/tap-report tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator ================================================ FILE: test/images/alpine.exec.Dockerfile ================================================ FROM alpine:3.18.0 as build ENV XDG_CACHE_HOME=/tmp/.cache ENV GOPATH=${HOME}/go ENV GO111MODULE=on ENV PATH="/usr/local/go/bin:${PATH}" ENV USER="test" ENV HOME="/home/test" COPY --from=golang:1.20.5-alpine /usr/local/go/ /usr/local/go/ RUN apk update RUN apk add --no-cache make build-base bash curl g++ git WORKDIR /opt COPY go.mod go.sum ./ RUN go mod download COPY . . RUN make build FROM alpine:3.15.4 RUN apk update RUN apk add --no-cache sudo bash zsh fish bash-completion git COPY --from=build /opt/dist/mani /usr/local/bin/mani RUN mani completion bash > /usr/share/bash-completion/completions/mani RUN addgroup -g 1000 -S test && adduser -u 1000 -S test -G test USER test WORKDIR /home/test # Setup example directory COPY --chown=test --from=build /opt/examples/mani.yaml /home/test/ RUN echo 'fpath=( ~/.zsh/completion "${fpath[@]}" ); autoload -Uz compinit && compinit -i' > /home/test/.zshrc RUN mkdir -p /home/test/.zsh/completion ~/.config/fish/completions RUN mani completion zsh > /home/test/.zsh/completion/_mani RUN mani completion fish > ~/.config/fish/completions/mani.fish RUN echo 'source /etc/profile.d/bash_completion.sh' > /home/test/.bashrc ================================================ FILE: test/images/alpine.test.Dockerfile ================================================ FROM alpine:3.21.0 ENV GOCACHE=/go/cache ENV GO111MODULE=on ENV PATH="/usr/local/go/bin:${PATH}" ENV USER="test" ENV HOME="/home/test" COPY --from=golang:1.25.5-alpine /usr/local/go/ /usr/local/go/ RUN apk update RUN apk add --no-cache make build-base bash curl g++ git RUN addgroup -g 1000 -S test && adduser -u 1000 -S test -G test WORKDIR /home/test COPY --chown=test go.mod go.sum ./ RUN go mod download COPY --chown=test . . COPY --chown=test ./test/scripts/git /usr/local/bin/git RUN make build-test && cp /home/test/dist/mani /usr/local/bin/mani USER test ================================================ FILE: test/integration/describe_test.go ================================================ package integration import ( "fmt" "testing" ) func TestDescribe(t *testing.T) { var cases = []TemplateTest{ // Projects { TestName: "Describe 0 projects when there's 0 projects", InputFiles: []string{"mani-empty/mani.yaml"}, TestCmd: "mani describe projects", WantErr: false, }, { TestName: "Describe 0 projects on non-existent tag", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani describe projects --tags lala", WantErr: true, }, { TestName: "Describe 0 projects on 2 non-matching tags", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani describe projects --tags frontend,cli", WantErr: false, }, { TestName: "Describe all projects", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani describe projects", WantErr: false, }, { TestName: "Describe projects matching 1 tag", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani describe projects --tags frontend", WantErr: false, }, { TestName: "Describe projects matching multiple tags", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani describe projects --tags misc,frontend", WantErr: false, }, { TestName: "Describe 1 project", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani describe projects pinto", WantErr: false, }, // Tasks { TestName: "Describe 0 tasks when no tasks exists ", InputFiles: []string{"mani-no-tasks/mani.yaml"}, TestCmd: "mani describe tasks", WantErr: false, }, { TestName: "Describe all tasks", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani describe tasks", WantErr: false, }, { TestName: "Describe 1 tasks", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani describe tasks status", WantErr: false, }, } for i, tt := range cases { cases[i].Golden = fmt.Sprintf("describe/golden-%d", i) cases[i].Index = i t.Run(tt.TestName, func(t *testing.T) { Run(t, cases[i]) }) } } ================================================ FILE: test/integration/exec_test.go ================================================ package integration import ( "fmt" "testing" ) func TestExec(t *testing.T) { var cases = []TemplateTest{ { TestName: "Should fail to exec when no configuration file found", InputFiles: []string{}, TestCmd: ` mani exec --all -o table ls `, WantErr: true, }, { TestName: "Should exec in zero projects", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani exec -o table ls `, WantErr: true, }, { TestName: "Should exec in all projects", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani exec --all -o table ls `, WantErr: false, }, { TestName: "Should exec when filtered on project name", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani exec -o table --projects pinto ls `, WantErr: false, }, { TestName: "Should exec when filtered on tags", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani exec -o table --tags frontend ls `, WantErr: false, }, { TestName: "Should exec when filtered on cwd", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync cd template-generator mani exec -o table --cwd pwd `, WantErr: false, }, { TestName: "Should dry run exec", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani exec -o table --dry-run --projects template-generator pwd `, WantErr: false, }, } for i, tt := range cases { cases[i].Golden = fmt.Sprintf("exec/golden-%d", i) cases[i].Index = i t.Run(tt.TestName, func(t *testing.T) { Run(t, cases[i]) }) } } ================================================ FILE: test/integration/golden/describe/golden-0/mani.yaml ================================================ ================================================ FILE: test/integration/golden/describe/golden-0/stdout.golden ================================================ Index: 0 Name: Describe 0 projects when there's 0 projects WantErr: false Cmd: mani describe projects --- No matching projects found ================================================ FILE: test/integration/golden/describe/golden-1/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/describe/golden-1/stdout.golden ================================================ Index: 1 Name: Describe 0 projects on non-existent tag WantErr: true Cmd: mani describe projects --tags lala --- error: cannot find tags `lala` ================================================ FILE: test/integration/golden/describe/golden-2/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/describe/golden-2/stdout.golden ================================================ Index: 2 Name: Describe 0 projects on 2 non-matching tags WantErr: false Cmd: mani describe projects --tags frontend,cli --- No matching projects found ================================================ FILE: test/integration/golden/describe/golden-3/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/describe/golden-3/stdout.golden ================================================ Index: 3 Name: Describe all projects WantErr: false Cmd: mani describe projects --- name: example sync: true path: . url: single_branch: false -- name: pinto sync: true path: frontend/pinto url: https://github.com/alajmo/pinto single_branch: false tags: frontend -- name: dashgrid sync: true path: frontend/dashgrid url: https://github.com/alajmo/dashgrid single_branch: false tags: frontend, misc -- name: template-generator sync: true url: https://github.com/alajmo/template-generator single_branch: false tags: cli env: branch: dev ================================================ FILE: test/integration/golden/describe/golden-4/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/describe/golden-4/stdout.golden ================================================ Index: 4 Name: Describe projects matching 1 tag WantErr: false Cmd: mani describe projects --tags frontend --- name: pinto sync: true path: frontend/pinto url: https://github.com/alajmo/pinto single_branch: false tags: frontend -- name: dashgrid sync: true path: frontend/dashgrid url: https://github.com/alajmo/dashgrid single_branch: false tags: frontend, misc ================================================ FILE: test/integration/golden/describe/golden-5/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/describe/golden-5/stdout.golden ================================================ Index: 5 Name: Describe projects matching multiple tags WantErr: false Cmd: mani describe projects --tags misc,frontend --- name: dashgrid sync: true path: frontend/dashgrid url: https://github.com/alajmo/dashgrid single_branch: false tags: frontend, misc ================================================ FILE: test/integration/golden/describe/golden-6/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/describe/golden-6/stdout.golden ================================================ Index: 6 Name: Describe 1 project WantErr: false Cmd: mani describe projects pinto --- name: pinto sync: true path: frontend/pinto url: https://github.com/alajmo/pinto single_branch: false tags: frontend ================================================ FILE: test/integration/golden/describe/golden-7/mani.yaml ================================================ projects: example: path: . tap-report: path: frontend/tap-report url: https://github.com/alajmo/tap-report tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator ================================================ FILE: test/integration/golden/describe/golden-7/stdout.golden ================================================ Index: 7 Name: Describe 0 tasks when no tasks exists WantErr: false Cmd: mani describe tasks --- No tasks ================================================ FILE: test/integration/golden/describe/golden-8/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/describe/golden-8/stdout.golden ================================================ Index: 8 Name: Describe all tasks WantErr: false Cmd: mani describe tasks --- name: fetch description: Fetch git theme: default target: all: false cwd: false projects: paths: tags: tags_expr: spec: output: stream parallel: false ignore_errors: false omit_empty_rows: false omit_empty_columns: false cmd: git fetch -- name: status description: theme: default target: all: false cwd: false projects: paths: tags: tags_expr: spec: output: stream parallel: false ignore_errors: false omit_empty_rows: false omit_empty_columns: false cmd: git status -- name: checkout description: theme: default target: all: false cwd: false projects: paths: tags: tags_expr: spec: output: stream parallel: false ignore_errors: false omit_empty_rows: false omit_empty_columns: false env: branch: dev cmd: git checkout $branch -- name: create-branch description: theme: default target: all: false cwd: false projects: paths: tags: tags_expr: spec: output: stream parallel: false ignore_errors: false omit_empty_rows: false omit_empty_columns: false cmd: git checkout -b $branch -- name: multi description: theme: default target: all: false cwd: false projects: paths: tags: tags_expr: spec: output: stream parallel: false ignore_errors: false omit_empty_rows: false omit_empty_columns: false cmd: echo "1st line " echo "2nd line" -- name: default-tags description: theme: default target: all: false cwd: false projects: paths: tags: frontend tags_expr: spec: output: stream parallel: false ignore_errors: false omit_empty_rows: false omit_empty_columns: false cmd: pwd -- name: default-projects description: theme: default target: all: false cwd: false projects: dashgrid paths: tags: tags_expr: spec: output: stream parallel: false ignore_errors: false omit_empty_rows: false omit_empty_columns: false cmd: pwd -- name: default-output description: theme: default target: all: false cwd: false projects: paths: tags: tags_expr: spec: output: table parallel: false ignore_errors: false omit_empty_rows: false omit_empty_columns: false cmd: pwd -- name: pwd description: theme: default target: all: false cwd: false projects: paths: tags: tags_expr: spec: output: stream parallel: false ignore_errors: false omit_empty_rows: false omit_empty_columns: false cmd: pwd -- name: submarine description: Submarine test theme: default target: all: false cwd: false projects: paths: tags: tags_expr: spec: output: table parallel: false ignore_errors: false omit_empty_rows: false omit_empty_columns: false cmd: echo 0 commands: - command-1 - command-2 - command-3 - pwd ================================================ FILE: test/integration/golden/describe/golden-9/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/describe/golden-9/stdout.golden ================================================ Index: 9 Name: Describe 1 tasks WantErr: false Cmd: mani describe tasks status --- name: status description: theme: default target: all: false cwd: false projects: paths: tags: tags_expr: spec: output: stream parallel: false ignore_errors: false omit_empty_rows: false omit_empty_columns: false cmd: git status ================================================ FILE: test/integration/golden/exec/golden-0/stdout.golden ================================================ Index: 0 Name: Should fail to exec when no configuration file found WantErr: true Cmd: mani exec --all -o table ls --- error: cannot find any configuration file [mani.yaml mani.yml .mani.yaml .mani.yml] in current directory or any of the parent directories ================================================ FILE: test/integration/golden/exec/golden-1/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/exec/golden-1/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/exec/golden-1/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/exec/golden-1/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/exec/golden-1/stdout.golden ================================================ Index: 1 Name: Should exec in zero projects WantErr: true Cmd: mani sync mani exec -o table ls --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ error: no matching projects found ================================================ FILE: test/integration/golden/exec/golden-2/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/exec/golden-2/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/exec/golden-2/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/exec/golden-2/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/exec/golden-2/stdout.golden ================================================ Index: 2 Name: Should exec in all projects WantErr: false Cmd: mani sync mani exec --all -o table ls --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | output --------------------+-------------------- example | frontend | mani.yaml | template-generator --------------------+-------------------- pinto | empty --------------------+-------------------- dashgrid | empty --------------------+-------------------- template-generator | empty ================================================ FILE: test/integration/golden/exec/golden-3/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/exec/golden-3/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/exec/golden-3/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/exec/golden-3/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/exec/golden-3/stdout.golden ================================================ Index: 3 Name: Should exec when filtered on project name WantErr: false Cmd: mani sync mani exec -o table --projects pinto ls --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | output ---------+-------- pinto | empty ================================================ FILE: test/integration/golden/exec/golden-4/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/exec/golden-4/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/exec/golden-4/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/exec/golden-4/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/exec/golden-4/stdout.golden ================================================ Index: 4 Name: Should exec when filtered on tags WantErr: false Cmd: mani sync mani exec -o table --tags frontend ls --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | output ----------+-------- pinto | empty ----------+-------- dashgrid | empty ================================================ FILE: test/integration/golden/exec/golden-5/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/exec/golden-5/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/exec/golden-5/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/exec/golden-5/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/exec/golden-5/stdout.golden ================================================ Index: 5 Name: Should exec when filtered on cwd WantErr: false Cmd: mani sync cd template-generator mani exec -o table --cwd pwd --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | output --------------------+------------------------------------------------------------- template-generator | /home/test/test/tmp/golden/exec/golden-5/template-generator ================================================ FILE: test/integration/golden/exec/golden-6/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/exec/golden-6/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/exec/golden-6/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/exec/golden-6/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/exec/golden-6/stdout.golden ================================================ Index: 6 Name: Should dry run exec WantErr: false Cmd: mani sync mani exec -o table --dry-run --projects template-generator pwd --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | output --------------------+-------- template-generator | pwd ================================================ FILE: test/integration/golden/init/golden-0/mani.yaml ================================================ projects: tasks: hello: desc: Print Hello World cmd: echo "Hello World" ================================================ FILE: test/integration/golden/init/golden-0/stdout.golden ================================================ Index: 0 Name: Initialize mani in empty directory WantErr: false Cmd: mani init --color=false --- Initialized mani repository in /home/test/test/tmp/golden/init/golden-0 - Created mani.yaml ================================================ FILE: test/integration/golden/init/golden-1/.gitignore ================================================ # mani # tap-report nested/template-generator # mani # ================================================ FILE: test/integration/golden/init/golden-1/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/init/golden-1/mani.yaml ================================================ projects: golden-1: path: . url: https://github.com/alajmo/pinto template-generator: path: nested/template-generator url: https://github.com/alajmo/template-generator tap-report: url: https://github.com/alajmo/tap-report tasks: hello: desc: Print Hello World cmd: echo "Hello World" ================================================ FILE: test/integration/golden/init/golden-1/nameless/empty ================================================ ================================================ FILE: test/integration/golden/init/golden-1/nested/template-generator/empty ================================================ ================================================ FILE: test/integration/golden/init/golden-1/stdout.golden ================================================ Index: 1 Name: Initialize mani with auto-discovery WantErr: false Cmd: (mkdir -p dashgrid && touch dashgrid/empty); (mkdir -p tap-report && touch tap-report/empty && cd tap-report && git init -b main && git remote add origin https://github.com/alajmo/tap-report); (mkdir -p nested/template-generator && touch nested/template-generator/empty && cd nested/template-generator && git init -b main && git remote add origin https://github.com/alajmo/template-generator); (mkdir nameless && touch nameless/empty); (git init -b main && git remote add origin https://github.com/alajmo/pinto) mani init --color=false --- Initialized empty Git repository in /home/test/test/tmp/golden/init/golden-1/tap-report/.git/ Initialized empty Git repository in /home/test/test/tmp/golden/init/golden-1/nested/template-generator/.git/ Initialized empty Git repository in /home/test/test/tmp/golden/init/golden-1/.git/ Initialized mani repository in /home/test/test/tmp/golden/init/golden-1 - Created mani.yaml - Created .gitignore Following projects were added to mani.yaml Project | Path --------------------+--------------------------- golden-1 | . template-generator | nested/template-generator tap-report | tap-report ================================================ FILE: test/integration/golden/init/golden-2/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/init/golden-2/stdout.golden ================================================ Index: 2 Name: Throw error when initialize in existing mani directory WantErr: true Cmd: mani init --color=false --- error: `/home/test/test/tmp/golden/init/golden-2` is already a mani directory ================================================ FILE: test/integration/golden/list/golden-0/mani.yaml ================================================ ================================================ FILE: test/integration/golden/list/golden-0/stdout.golden ================================================ Index: 0 Name: List 0 projects WantErr: false Cmd: mani list projects --- No matching projects found ================================================ FILE: test/integration/golden/list/golden-1/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-1/stdout.golden ================================================ Index: 1 Name: List 0 projects on non-existent tag WantErr: true Cmd: mani list projects --tags lala --- error: cannot find tags `lala` ================================================ FILE: test/integration/golden/list/golden-10/mani.yaml ================================================ ================================================ FILE: test/integration/golden/list/golden-10/stdout.golden ================================================ Index: 10 Name: List empty projects tree WantErr: false Cmd: mani list projects --tree --- ================================================ FILE: test/integration/golden/list/golden-11/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-11/stdout.golden ================================================ Index: 11 Name: List full tree WantErr: false Cmd: mani list projects --tree --- ┌─ . ├─ frontend │ ├─ pinto │ └─ dashgrid └─ template-generator ================================================ FILE: test/integration/golden/list/golden-12/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-12/stdout.golden ================================================ Index: 12 Name: List tree filtered on tag WantErr: false Cmd: mani list projects --tree --tags frontend --- ── frontend ├─ pinto └─ dashgrid ================================================ FILE: test/integration/golden/list/golden-13/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-13/stdout.golden ================================================ Index: 13 Name: List all tags WantErr: false Cmd: mani list tags --- Tag | Project ----------+-------------------- frontend | pinto | dashgrid misc | dashgrid cli | template-generator ================================================ FILE: test/integration/golden/list/golden-14/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-14/stdout.golden ================================================ Index: 14 Name: List two tags WantErr: false Cmd: mani list tags frontend misc --- Tag | Project ----------+---------- frontend | pinto | dashgrid misc | dashgrid ================================================ FILE: test/integration/golden/list/golden-15/mani.yaml ================================================ projects: example: path: . tap-report: path: frontend/tap-report url: https://github.com/alajmo/tap-report tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator ================================================ FILE: test/integration/golden/list/golden-15/stdout.golden ================================================ Index: 15 Name: List 0 tasks when no tasks exists WantErr: false Cmd: mani list tasks --- No tasks ================================================ FILE: test/integration/golden/list/golden-16/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-16/stdout.golden ================================================ Index: 16 Name: List all tasks WantErr: false Cmd: mani list tasks --- Task | Description ------------------+---------------- fetch | Fetch git status | checkout | create-branch | multi | default-tags | default-projects | default-output | pwd | submarine | Submarine test ================================================ FILE: test/integration/golden/list/golden-17/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-17/stdout.golden ================================================ Index: 17 Name: List two args WantErr: false Cmd: mani list tasks fetch status --- Task | Description --------+------------- fetch | Fetch git status | ================================================ FILE: test/integration/golden/list/golden-2/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-2/stdout.golden ================================================ Index: 2 Name: List 0 projects on 2 non-matching tags WantErr: false Cmd: mani list projects --tags frontend,cli --- No matching projects found ================================================ FILE: test/integration/golden/list/golden-3/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-3/stdout.golden ================================================ Index: 3 Name: List multiple projects WantErr: false Cmd: mani list projects --- Project | Tag --------------------+---------------- example | pinto | frontend dashgrid | frontend, misc template-generator | cli ================================================ FILE: test/integration/golden/list/golden-4/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-4/stdout.golden ================================================ Index: 4 Name: List only project names and no description/tags WantErr: false Cmd: mani list projects --output table --headers project --- Project -------------------- example pinto dashgrid template-generator ================================================ FILE: test/integration/golden/list/golden-5/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-5/stdout.golden ================================================ Index: 5 Name: List projects matching 1 tag WantErr: false Cmd: mani list projects --tags frontend --- Project | Tag ----------+---------------- pinto | frontend dashgrid | frontend, misc ================================================ FILE: test/integration/golden/list/golden-6/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-6/stdout.golden ================================================ Index: 6 Name: List projects matching multiple tags WantErr: false Cmd: mani list projects --tags misc,frontend --- Project | Tag ----------+---------------- dashgrid | frontend, misc ================================================ FILE: test/integration/golden/list/golden-7/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-7/stdout.golden ================================================ Index: 7 Name: List two projects WantErr: false Cmd: mani list projects pinto dashgrid --- Project | Tag ----------+---------------- pinto | frontend dashgrid | frontend, misc ================================================ FILE: test/integration/golden/list/golden-8/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-8/stdout.golden ================================================ Index: 8 Name: List projects matching 1 dir WantErr: false Cmd: mani list projects --paths frontend --- Project | Tag ----------+---------------- pinto | frontend dashgrid | frontend, misc ================================================ FILE: test/integration/golden/list/golden-9/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/list/golden-9/stdout.golden ================================================ Index: 9 Name: List 0 projects with no matching paths WantErr: true Cmd: mani list projects --paths hello --- error: cannot find paths `hello` ================================================ FILE: test/integration/golden/run/golden-0/stdout.golden ================================================ Index: 0 Name: Should fail to run when no configuration file found WantErr: true Cmd: mani run pwd --all --- error: cannot find any configuration file [mani.yaml mani.yml .mani.yaml .mani.yml] in current directory or any of the parent directories ================================================ FILE: test/integration/golden/run/golden-1/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/run/golden-1/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-1/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-1/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/run/golden-1/stdout.golden ================================================ Index: 1 Name: Should run in zero projects WantErr: true Cmd: mani sync mani run pwd -o table --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ error: no matching projects found ================================================ FILE: test/integration/golden/run/golden-10/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/run/golden-10/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-10/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-10/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/run/golden-10/stdout.golden ================================================ Index: 10 Name: Should run multiple commands WantErr: false Cmd: mani sync mani run pwd multi -o table --all --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | pwd | multi --------------------+-------------------------------------------------------------+----------- example | /home/test/test/tmp/golden/run/golden-10 | 1st line | | 2nd line --------------------+-------------------------------------------------------------+----------- pinto | /home/test/test/tmp/golden/run/golden-10/frontend/pinto | 1st line | | 2nd line --------------------+-------------------------------------------------------------+----------- dashgrid | /home/test/test/tmp/golden/run/golden-10/frontend/dashgrid | 1st line | | 2nd line --------------------+-------------------------------------------------------------+----------- template-generator | /home/test/test/tmp/golden/run/golden-10/template-generator | 1st line | | 2nd line ================================================ FILE: test/integration/golden/run/golden-11/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/run/golden-11/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-11/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-11/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/run/golden-11/stdout.golden ================================================ Index: 11 Name: Should run sub-commands WantErr: false Cmd: mani sync mani run submarine --all --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | command-1 | command-2 | command-3 | pwd | submarine --------------------+-----------+-----------+-----------+-------------------------------------------------------------+----------- example | 1 | 2 | 3 | /home/test/test/tmp/golden/run/golden-11 | 0 --------------------+-----------+-----------+-----------+-------------------------------------------------------------+----------- pinto | 1 | 2 | 3 | /home/test/test/tmp/golden/run/golden-11/frontend/pinto | 0 --------------------+-----------+-----------+-----------+-------------------------------------------------------------+----------- dashgrid | 1 | 2 | 3 | /home/test/test/tmp/golden/run/golden-11/frontend/dashgrid | 0 --------------------+-----------+-----------+-----------+-------------------------------------------------------------+----------- template-generator | 1 | 2 | 3 | /home/test/test/tmp/golden/run/golden-11/template-generator | 0 ================================================ FILE: test/integration/golden/run/golden-2/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/run/golden-2/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-2/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-2/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/run/golden-2/stdout.golden ================================================ Index: 2 Name: Should run in all projects WantErr: false Cmd: mani sync mani run --all pwd --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ TASK [pwd] example | /home/test/test/tmp/golden/run/golden-2 TASK [pwd] pinto | /home/test/test/tmp/golden/run/golden-2/frontend/pinto TASK [pwd] dashgrid | /home/test/test/tmp/golden/run/golden-2/frontend/dashgrid TASK [pwd] template-generator | /home/test/test/tmp/golden/run/golden-2/template-generator ================================================ FILE: test/integration/golden/run/golden-3/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/run/golden-3/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-3/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-3/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/run/golden-3/stdout.golden ================================================ Index: 3 Name: Should run when filtered on project WantErr: false Cmd: mani sync mani run -o table --projects pinto pwd --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | pwd ---------+-------------------------------------------------------- pinto | /home/test/test/tmp/golden/run/golden-3/frontend/pinto ================================================ FILE: test/integration/golden/run/golden-4/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/run/golden-4/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-4/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-4/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/run/golden-4/stdout.golden ================================================ Index: 4 Name: Should run when filtered on tags WantErr: false Cmd: mani sync mani run -o table --tags frontend pwd --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | pwd ----------+----------------------------------------------------------- pinto | /home/test/test/tmp/golden/run/golden-4/frontend/pinto ----------+----------------------------------------------------------- dashgrid | /home/test/test/tmp/golden/run/golden-4/frontend/dashgrid ================================================ FILE: test/integration/golden/run/golden-5/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/run/golden-5/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-5/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-5/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/run/golden-5/stdout.golden ================================================ Index: 5 Name: Should run when filtered on cwd WantErr: false Cmd: mani sync cd template-generator mani run -o table --cwd pwd --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | pwd --------------------+------------------------------------------------------------ template-generator | /home/test/test/tmp/golden/run/golden-5/template-generator ================================================ FILE: test/integration/golden/run/golden-6/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/run/golden-6/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-6/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-6/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/run/golden-6/stdout.golden ================================================ Index: 6 Name: Should run on default tags WantErr: false Cmd: mani sync mani run -o table default-tags --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | default-tags ----------+----------------------------------------------------------- pinto | /home/test/test/tmp/golden/run/golden-6/frontend/pinto ----------+----------------------------------------------------------- dashgrid | /home/test/test/tmp/golden/run/golden-6/frontend/dashgrid ================================================ FILE: test/integration/golden/run/golden-7/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/run/golden-7/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-7/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-7/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/run/golden-7/stdout.golden ================================================ Index: 7 Name: Should run on default projects WantErr: false Cmd: mani sync mani run -o table default-projects --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | default-projects ----------+----------------------------------------------------------- dashgrid | /home/test/test/tmp/golden/run/golden-7/frontend/dashgrid ================================================ FILE: test/integration/golden/run/golden-8/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/run/golden-8/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-8/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-8/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/run/golden-8/stdout.golden ================================================ Index: 8 Name: Should print table when output set to table in task WantErr: false Cmd: mani sync mani run default-output -p dashgrid --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | default-output ----------+----------------------------------------------------------- dashgrid | /home/test/test/tmp/golden/run/golden-8/frontend/dashgrid ================================================ FILE: test/integration/golden/run/golden-9/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/run/golden-9/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-9/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/run/golden-9/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/run/golden-9/stdout.golden ================================================ Index: 9 Name: Should dry run WantErr: false Cmd: mani sync mani run --dry-run --projects template-generator -o table pwd --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ project | pwd --------------------+----- template-generator | pwd ================================================ FILE: test/integration/golden/sync/golden-0/stdout.golden ================================================ Index: 0 Name: Throw error when trying to sync a non-existing mani repository WantErr: true Cmd: mani sync --- error: cannot find any configuration file [mani.yaml mani.yml .mani.yaml .mani.yml] in current directory or any of the parent directories ================================================ FILE: test/integration/golden/sync/golden-1/.gitignore ================================================ outside # mani # template-generator frontend/dashgrid frontend/pinto # mani # outside frontend/pinto-vim ================================================ FILE: test/integration/golden/sync/golden-1/frontend/dashgrid/empty ================================================ ================================================ FILE: test/integration/golden/sync/golden-1/frontend/pinto/empty ================================================ ================================================ FILE: test/integration/golden/sync/golden-1/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/sync/golden-1/stdout.golden ================================================ Index: 1 Name: Should sync WantErr: false Cmd: mani sync --- Project [pinto] Project [dashgrid] Project [template-generator] Project | Synced --------------------+-------- example | ✓ pinto | ✓ dashgrid | ✓ template-generator | ✓ ================================================ FILE: test/integration/golden/version/golden-0/stdout.golden ================================================ Index: 0 Name: Print version when no mani config is found WantErr: false Cmd: mani --version --- Version: dev Commit: none Date: n/a ================================================ FILE: test/integration/golden/version/golden-1/mani.yaml ================================================ projects: example: path: . pinto: path: frontend/pinto url: https://github.com/alajmo/pinto tags: [frontend] dashgrid: path: frontend/dashgrid/../dashgrid url: https://github.com/alajmo/dashgrid tags: [frontend, misc] template-generator: url: https://github.com/alajmo/template-generator tags: [cli] env: branch: dev env: VERSION: v.1.2.3 TEST: $(echo "Hello World") NO_COLOR: true specs: table: output: table parallel: false ignore_errors: false tasks: fetch: desc: Fetch git cmd: git fetch status: cmd: git status checkout: env: branch: dev cmd: git checkout $branch create-branch: cmd: git checkout -b $branch multi: cmd: | # Multi line command echo "1st line " echo "2nd line" default-tags: target: tags: [frontend] cmd: pwd default-projects: target: projects: [dashgrid] cmd: pwd default-output: spec: output: table cmd: pwd pwd: pwd submarine: desc: Submarine test cmd: echo 0 spec: table commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/integration/golden/version/golden-1/stdout.golden ================================================ Index: 1 Name: Print version when mani config is found WantErr: false Cmd: mani --version --- Version: dev Commit: none Date: n/a ================================================ FILE: test/integration/init_test.go ================================================ package integration import ( "fmt" "testing" ) func TestInit(t *testing.T) { var cases = []TemplateTest{ { TestName: "Initialize mani in empty directory", InputFiles: []string{}, TestCmd: "mani init --color=false", WantErr: false, }, { TestName: "Initialize mani with auto-discovery", InputFiles: []string{}, TestCmd: ` (mkdir -p dashgrid && touch dashgrid/empty); (mkdir -p tap-report && touch tap-report/empty && cd tap-report && git init -b main && git remote add origin https://github.com/alajmo/tap-report); (mkdir -p nested/template-generator && touch nested/template-generator/empty && cd nested/template-generator && git init -b main && git remote add origin https://github.com/alajmo/template-generator); (mkdir nameless && touch nameless/empty); (git init -b main && git remote add origin https://github.com/alajmo/pinto) mani init --color=false `, WantErr: false, }, { TestName: "Throw error when initialize in existing mani directory", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani init --color=false", WantErr: true, }, } for i, tt := range cases { cases[i].Golden = fmt.Sprintf("init/golden-%d", i) cases[i].Index = i t.Run(tt.TestName, func(t *testing.T) { Run(t, cases[i]) }) } } ================================================ FILE: test/integration/list_test.go ================================================ package integration import ( "fmt" "testing" ) func TestList(t *testing.T) { var cases = []TemplateTest{ // Projects { TestName: "List 0 projects", InputFiles: []string{"mani-empty/mani.yaml"}, TestCmd: "mani list projects", WantErr: false, }, { TestName: "List 0 projects on non-existent tag", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list projects --tags lala", WantErr: true, }, { TestName: "List 0 projects on 2 non-matching tags", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list projects --tags frontend,cli", WantErr: false, }, { TestName: "List multiple projects", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list projects", WantErr: false, }, { TestName: "List only project names and no description/tags", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list projects --output table --headers project", WantErr: false, }, { TestName: "List projects matching 1 tag", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list projects --tags frontend", WantErr: false, }, { TestName: "List projects matching multiple tags", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list projects --tags misc,frontend", WantErr: false, }, { TestName: "List two projects", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list projects pinto dashgrid", WantErr: false, }, { TestName: "List projects matching 1 dir", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list projects --paths frontend", WantErr: false, }, { TestName: "List 0 projects with no matching paths", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list projects --paths hello", WantErr: true, }, { TestName: "List empty projects tree", InputFiles: []string{"mani-empty/mani.yaml"}, TestCmd: "mani list projects --tree", WantErr: false, }, { TestName: "List full tree", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list projects --tree", WantErr: false, }, { TestName: "List tree filtered on tag", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list projects --tree --tags frontend", WantErr: false, }, // Tags { TestName: "List all tags", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list tags", Golden: "list/tags", WantErr: false, }, { TestName: "List two tags", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list tags frontend misc", Golden: "list/tags-2-args", WantErr: false, }, // Tasks { TestName: "List 0 tasks when no tasks exists ", InputFiles: []string{"mani-no-tasks/mani.yaml"}, TestCmd: "mani list tasks", Golden: "list/tasks-empty", WantErr: false, }, { TestName: "List all tasks", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list tasks", Golden: "list/tasks", WantErr: false, }, { TestName: "List two args", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani list tasks fetch status", Golden: "list/tasks-2-args", WantErr: false, }, } for i, tt := range cases { cases[i].Golden = fmt.Sprintf("list/golden-%d", i) cases[i].Index = i t.Run(tt.TestName, func(t *testing.T) { Run(t, cases[i]) }) } } ================================================ FILE: test/integration/main_test.go ================================================ package integration import ( "flag" "fmt" "log" "strings" "os" "os/exec" "path" "path/filepath" "reflect" "runtime" "testing" "github.com/gookit/color" "github.com/kr/pretty" "github.com/otiai10/copy" ) var tmpPath = "/home/test/test/tmp" var rootDir = "" var debug = flag.Bool("debug", false, "debug") var update = flag.Bool("update", false, "update golden files") var clean = flag.Bool("clean", false, "Clean tmp directory after run") var copyOpts = copy.Options{ Skip: func(src string) (bool, error) { return strings.HasSuffix(src, ".git"), nil }, } type TemplateTest struct { TestName string InputFiles []string TestCmd string Golden string Ignore bool WantErr bool Index int } func (tt TemplateTest) GoldenOutput(output []byte) []byte { out := string(output) testCmd := strings.ReplaceAll(tt.TestCmd, "\t", "") testCmd = strings.TrimLeft(testCmd, "\n") golden := fmt.Sprintf( "Index: %d\nName: %s\nWantErr: %t\nCmd:\n%s\n\n---\n%s", tt.Index, tt.TestName, tt.WantErr, testCmd, out, ) return []byte(golden) } type TestFile struct { t *testing.T name string dir string } func NewGoldenFile(t *testing.T, name string) *TestFile { return &TestFile{t: t, name: "stdout.golden", dir: filepath.Join("golden", name)} } func (tf *TestFile) Dir() string { tf.t.Helper() _, filename, _, ok := runtime.Caller(0) if !ok { tf.t.Fatal("problems recovering caller information") } return filepath.Join(filepath.Dir(filename), tf.dir) } func (tf *TestFile) path() string { tf.t.Helper() _, filename, _, ok := runtime.Caller(0) if !ok { tf.t.Fatal("problems recovering caller information") } return filepath.Join(filepath.Dir(filename), tf.dir, tf.name) } func (tf *TestFile) Write(content string) { tf.t.Helper() err := os.MkdirAll(filepath.Dir(tf.path()), os.ModePerm) if err != nil { tf.t.Fatalf("could not create directory %s: %v", tf.name, err) } err = os.WriteFile(tf.path(), []byte(content), 0644) if err != nil { tf.t.Fatalf("could not write %s: %v", tf.name, err) } } func clearGolden(goldenDir string) { // Guard against accidentally deleting outside directory if strings.Contains(goldenDir, "golden") { _ = os.RemoveAll(goldenDir) } } func clearTmp() { dir, _ := os.ReadDir(path.Join(tmpPath, "golden")) for _, d := range dir { f := path.Join(tmpPath, "golden", path.Join([]string{d.Name()}...)) _ = os.RemoveAll(f) } } func diff(expected, actual any) []string { return pretty.Diff(expected, actual) } // 1. Clean tmp directory // 2. Create mani binary // 3. cd into test/tmp func TestMain(m *testing.M) { clearTmp() var wd, err = os.Getwd() if err != nil { log.Fatalf("could not get wd") } rootDir = filepath.Dir(wd) err = os.Chdir("../..") if err != nil { log.Fatalf("could not change dir: %v", err) } os.Exit(m.Run()) } func printDirectoryContent(dir string) { err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if info.IsDir() && info.Name() == ".git" { return filepath.SkipDir } if err != nil { return err } return nil }) if err != nil { log.Fatalf("could not walk dir: %v", err) } } func countFilesAndFolders(dir string) int { var count = 0 err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if info.IsDir() && info.Name() == ".git" { return filepath.SkipDir } count = count + 1 if err != nil { return err } return nil }) if err != nil { log.Fatalf("could not walk dir: %v", err) } return count } func Run(t *testing.T, tt TemplateTest) { log.SetFlags(0) var tmpDir = filepath.Join(tmpPath, "golden", tt.Golden) if _, err := os.Stat(tmpDir); os.IsNotExist(err) { err = os.MkdirAll(tmpDir, os.ModePerm) if err != nil { t.Fatalf("could not create directory at %s: %v", tmpPath, err) } } err := os.Chdir(tmpDir) if err != nil { t.Fatalf("could not change dir: %v", err) } var fixturesDir = filepath.Join(rootDir, "fixtures") t.Cleanup(func() { if *clean { clearTmp() } }) // Copy fixture files for _, file := range tt.InputFiles { var configPath = filepath.Join(fixturesDir, file) err := copy.Copy(configPath, filepath.Base(file), copyOpts) if err != nil { t.Fatalf("\x1b[31;1m%s\x1b[0m\n", fmt.Sprintf("error: %s", err)) } } // Run test command cmd := exec.Command("sh", "-c", tt.TestCmd) cmd.Env = os.Environ() output, err := cmd.CombinedOutput() // TEST: Check we get error if we want error if (err != nil) != tt.WantErr { t.Fatalf("%s\nexpected (err != nil) to be %v, but got %v. err: %v", output, tt.WantErr, err != nil, err) } if *debug { fmt.Println(tt.TestCmd) fmt.Println(string(output)) } // Save output from command as golden file golden := NewGoldenFile(t, tt.Golden) // TODO actual := string(tt.GoldenOutput(output)) var goldenFile = path.Join(tmpDir, "stdout.golden") // Write output to tmp file which will be used to compare with golden files // TODO err = os.WriteFile(goldenFile, tt.GoldenOutput(output), 0644) if err != nil { t.Fatalf("could not write %s: %v", goldenFile, err) } if *update { clearGolden(golden.Dir()) // Write stdout of test command to golden file golden.Write(actual) err := copy.Copy(tmpDir, golden.Dir(), copyOpts) if err != nil { t.Fatalf("\x1b[31;1m%s\x1b[0m\n", fmt.Sprintf("error: %s", err)) } } else { err := filepath.Walk(golden.Dir(), func(path string, info os.FileInfo, err error) error { // Skip project files, they require an empty file to be added to git if filepath.Base(path) == "empty" { return nil } if info.IsDir() { return nil } if path == tmpDir { return nil } if err != nil { t.Fatalf("Error: %v", err) } tmpPath := filepath.Join(tmpDir, filepath.Base(path)) actual, err := os.ReadFile(tmpPath) if err != nil { t.Fatalf("Error: %v", err) } expected, err := os.ReadFile(path) if err != nil { t.Fatalf("Error: %v", err) } // TEST: Check file content difference for each generated file if !tt.Ignore && !reflect.DeepEqual(actual, expected) { fmt.Println(color.FgGreen.Sprintf("EXPECTED:")) fmt.Println("<---------------------") fmt.Println(string(expected)) fmt.Println("--------------------->") fmt.Println() fmt.Println(color.FgRed.Sprintf("ACTUAL:")) fmt.Println("<---------------------") fmt.Println(string(actual)) fmt.Println("--------------------->") t.Fatalf("\nfile: %v\ndiff: %v", color.FgBlue.Sprint(path), diff(expected, actual)) } return nil }) // TEST: Check the total amount of files and directories match expectedCount := countFilesAndFolders(golden.Dir()) actualCount := countFilesAndFolders(tmpDir) if expectedCount != actualCount { fmt.Println(color.FgGreen.Sprintf("EXPECTED:")) printDirectoryContent(golden.Dir()) fmt.Println(color.FgRed.Sprintf("ACTUAL:")) printDirectoryContent(tmpDir) t.Fatalf("\nexpected count: %v\nactual count: %v", expectedCount, actualCount) } if err != nil { t.Fatalf("Error: %v", err) } } } ================================================ FILE: test/integration/run_test.go ================================================ package integration import ( "fmt" "testing" ) func TestRun(t *testing.T) { var cases = []TemplateTest{ { TestName: "Should fail to run when no configuration file found", InputFiles: []string{}, TestCmd: ` mani run pwd --all `, WantErr: true, }, { TestName: "Should run in zero projects", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani run pwd -o table `, WantErr: true, }, { TestName: "Should run in all projects", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani run --all pwd `, WantErr: false, }, { TestName: "Should run when filtered on project", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani run -o table --projects pinto pwd `, WantErr: false, }, { TestName: "Should run when filtered on tags", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani run -o table --tags frontend pwd `, WantErr: false, }, { TestName: "Should run when filtered on cwd", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync cd template-generator mani run -o table --cwd pwd `, WantErr: false, }, { TestName: "Should run on default tags", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani run -o table default-tags `, WantErr: false, }, { TestName: "Should run on default projects", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani run -o table default-projects `, WantErr: false, }, { TestName: "Should print table when output set to table in task", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani run default-output -p dashgrid `, WantErr: false, }, { TestName: "Should dry run", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani run --dry-run --projects template-generator -o table pwd `, WantErr: false, }, { TestName: "Should run multiple commands", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani run pwd multi -o table --all `, WantErr: false, }, { TestName: "Should run sub-commands", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync mani run submarine --all `, WantErr: false, }, } for i, tt := range cases { cases[i].Golden = fmt.Sprintf("run/golden-%d", i) cases[i].Index = i t.Run(tt.TestName, func(t *testing.T) { Run(t, cases[i]) }) } } ================================================ FILE: test/integration/sync_test.go ================================================ package integration import ( "fmt" "testing" ) func TestSync(t *testing.T) { var cases = []TemplateTest{ { TestName: "Throw error when trying to sync a non-existing mani repository", InputFiles: []string{}, TestCmd: ` mani sync `, WantErr: true, }, { TestName: "Should sync", InputFiles: []string{"mani-advanced/mani.yaml", "mani-advanced/.gitignore"}, TestCmd: ` mani sync `, WantErr: false, }, } for i, tt := range cases { cases[i].Golden = fmt.Sprintf("sync/golden-%d", i) cases[i].Index = i t.Run(tt.TestName, func(t *testing.T) { Run(t, cases[i]) }) } } ================================================ FILE: test/integration/version_test.go ================================================ package integration import ( "fmt" "testing" ) func TestVersion(t *testing.T) { var cases = []TemplateTest{ { TestName: "Print version when no mani config is found", InputFiles: []string{}, TestCmd: "mani --version", Ignore: true, WantErr: false, }, { TestName: "Print version when mani config is found", InputFiles: []string{"mani-advanced/mani.yaml"}, TestCmd: "mani --version", Ignore: true, WantErr: false, }, } for i, tt := range cases { cases[i].Golden = fmt.Sprintf("version/golden-%d", i) cases[i].Index = i t.Run(tt.TestName, func(t *testing.T) { Run(t, cases[i]) }) } } ================================================ FILE: test/playground/.gitignore ================================================ # mani # template-generator kaka frontend/tap-report frontend/dashgrid # mani # ================================================ FILE: test/playground/imports/many-projects.yaml ================================================ projects: template-generator-1: path: ../many/template-generator-1 url: https://github.com/alajmo/template-generator template-generator-2: path: ../many/template-generator-2 url: https://github.com/alajmo/template-generator template-generator-3: path: ../many/template-generator-3 url: https://github.com/alajmo/template-generator template-generator-4: path: ../many/template-generator-4 url: https://github.com/alajmo/template-generator template-generator-5: path: ../many/template-generator-5 url: https://github.com/alajmo/template-generator template-generator-6: path: ../many/template-generator-6 url: https://github.com/alajmo/template-generator template-generator-7: path: ../many/template-generator-7 url: https://github.com/alajmo/template-generator template-generator-8: path: ../many/template-generator-8 url: https://github.com/alajmo/template-generator template-generator-9: path: ../many/template-generator-9 url: https://github.com/alajmo/template-generator template-generator-10: path: ../many/template-generator-10 url: https://github.com/alajmo/template-generator template-generator-11: path: ../many/template-generator-11 url: https://github.com/alajmo/template-generator template-generator-12: path: ../many/template-generator-12 url: https://github.com/alajmo/template-generator template-generator-13: path: ../many/template-generator-13 url: https://github.com/alajmo/template-generator template-generator-14: path: ../many/template-generator-14 url: https://github.com/alajmo/template-generator template-generator-15: path: ../many/template-generator-15 url: https://github.com/alajmo/template-generator template-generator-16: path: ../many/template-generator-16 url: https://github.com/alajmo/template-generator template-generator-17: path: ../many/template-generator-17 url: https://github.com/alajmo/template-generator template-generator-18: path: ../many/template-generator-18 url: https://github.com/alajmo/template-generator template-generator-19: path: ../many/template-generator-19 url: https://github.com/alajmo/template-generator template-generator-20: path: ../many/template-generator-20 url: https://github.com/alajmo/template-generator template-generator-21: path: ../many/template-generator-21 url: https://github.com/alajmo/template-generator template-generator-22: path: ../many/template-generator-22 url: https://github.com/alajmo/template-generator template-generator-23: path: ../many/template-generator-23 url: https://github.com/alajmo/template-generator template-generator-24: path: ../many/template-generator-24 url: https://github.com/alajmo/template-generator template-generator-25: path: ../many/template-generator-25 url: https://github.com/alajmo/template-generator template-generator-26: path: ../many/template-generator-26 url: https://github.com/alajmo/template-generator template-generator-27: path: ../many/template-generator-27 url: https://github.com/alajmo/template-generator template-generator-28: path: ../many/template-generator-28 url: https://github.com/alajmo/template-generator template-generator-29: path: ../many/template-generator-29 url: https://github.com/alajmo/template-generator template-generator-30: path: ../many/template-generator-30 url: https://github.com/alajmo/template-generator template-generator-31: path: ../many/template-generator-31 url: https://github.com/alajmo/template-generator template-generator-32: path: ../many/template-generator-32 url: https://github.com/alajmo/template-generator template-generator-33: path: ../many/template-generator-33 url: https://github.com/alajmo/template-generator template-generator-34: path: ../many/template-generator-34 url: https://github.com/alajmo/template-generator template-generator-35: path: ../many/template-generator-35 url: https://github.com/alajmo/template-generator template-generator-36: path: ../many/template-generator-36 url: https://github.com/alajmo/template-generator template-generator-37: path: ../many/template-generator-37 url: https://github.com/alajmo/template-generator template-generator-38: path: ../many/template-generator-38 url: https://github.com/alajmo/template-generator template-generator-39: path: ../many/template-generator-39 url: https://github.com/alajmo/template-generator template-generator-40: path: ../many/template-generator-40 url: https://github.com/alajmo/template-generator template-generator-41: path: ../many/template-generator-41 url: https://github.com/alajmo/template-generator template-generator-42: path: ../many/template-generator-42 url: https://github.com/alajmo/template-generator template-generator-43: path: ../many/template-generator-43 url: https://github.com/alajmo/template-generator template-generator-44: path: ../many/template-generator-44 url: https://github.com/alajmo/template-generator template-generator-45: path: ../many/template-generator-45 url: https://github.com/alajmo/template-generator template-generator-46: path: ../many/template-generator-46 url: https://github.com/alajmo/template-generator template-generator-47: path: ../many/template-generator-47 url: https://github.com/alajmo/template-generator template-generator-48: path: ../many/template-generator-48 url: https://github.com/alajmo/template-generator template-generator-49: path: ../many/template-generator-49 url: https://github.com/alajmo/template-generator template-generator-50: path: ../many/template-generator-50 url: https://github.com/alajmo/template-generator template-generator-51: path: ../many/template-generator-51 url: https://github.com/alajmo/template-generator template-generator-52: path: ../many/template-generator-52 url: https://github.com/alajmo/template-generator template-generator-53: path: ../many/template-generator-53 url: https://github.com/alajmo/template-generator template-generator-54: path: ../many/template-generator-54 url: https://github.com/alajmo/template-generator template-generator-55: path: ../many/template-generator-55 url: https://github.com/alajmo/template-generator template-generator-56: path: ../many/template-generator-56 url: https://github.com/alajmo/template-generator template-generator-57: path: ../many/template-generator-57 url: https://github.com/alajmo/template-generator template-generator-58: path: ../many/template-generator-58 url: https://github.com/alajmo/template-generator template-generator-59: path: ../many/template-generator-59 url: https://github.com/alajmo/template-generator template-generator-60: path: ../many/template-generator-60 url: https://github.com/alajmo/template-generator template-generator-61: path: ../many/template-generator-61 url: https://github.com/alajmo/template-generator template-generator-62: path: ../many/template-generator-62 url: https://github.com/alajmo/template-generator template-generator-63: path: ../many/template-generator-63 url: https://github.com/alajmo/template-generator template-generator-64: path: ../many/template-generator-64 url: https://github.com/alajmo/template-generator template-generator-65: path: ../many/template-generator-65 url: https://github.com/alajmo/template-generator template-generator-66: path: ../many/template-generator-66 url: https://github.com/alajmo/template-generator template-generator-67: path: ../many/template-generator-67 url: https://github.com/alajmo/template-generator template-generator-68: path: ../many/template-generator-68 url: https://github.com/alajmo/template-generator template-generator-69: path: ../many/template-generator-69 url: https://github.com/alajmo/template-generator template-generator-70: path: ../many/template-generator-70 url: https://github.com/alajmo/template-generator template-generator-71: path: ../many/template-generator-71 url: https://github.com/alajmo/template-generator template-generator-72: path: ../many/template-generator-72 url: https://github.com/alajmo/template-generator template-generator-73: path: ../many/template-generator-73 url: https://github.com/alajmo/template-generator template-generator-74: path: ../many/template-generator-74 url: https://github.com/alajmo/template-generator template-generator-75: path: ../many/template-generator-75 url: https://github.com/alajmo/template-generator template-generator-76: path: ../many/template-generator-76 url: https://github.com/alajmo/template-generator template-generator-77: path: ../many/template-generator-77 url: https://github.com/alajmo/template-generator template-generator-78: path: ../many/template-generator-78 url: https://github.com/alajmo/template-generator template-generator-79: path: ../many/template-generator-79 url: https://github.com/alajmo/template-generator template-generator-80: path: ../many/template-generator-80 url: https://github.com/alajmo/template-generator template-generator-81: path: ../many/template-generator-81 url: https://github.com/alajmo/template-generator template-generator-82: path: ../many/template-generator-82 url: https://github.com/alajmo/template-generator template-generator-83: path: ../many/template-generator-83 url: https://github.com/alajmo/template-generator template-generator-84: path: ../many/template-generator-84 url: https://github.com/alajmo/template-generator template-generator-85: path: ../many/template-generator-85 url: https://github.com/alajmo/template-generator template-generator-86: path: ../many/template-generator-86 url: https://github.com/alajmo/template-generator template-generator-87: path: ../many/template-generator-87 url: https://github.com/alajmo/template-generator template-generator-88: path: ../many/template-generator-88 url: https://github.com/alajmo/template-generator template-generator-89: path: ../many/template-generator-89 url: https://github.com/alajmo/template-generator template-generator-90: path: ../many/template-generator-90 url: https://github.com/alajmo/template-generator template-generator-91: path: ../many/template-generator-91 url: https://github.com/alajmo/template-generator template-generator-92: path: ../many/template-generator-92 url: https://github.com/alajmo/template-generator template-generator-93: path: ../many/template-generator-93 url: https://github.com/alajmo/template-generator template-generator-94: path: ../many/template-generator-94 url: https://github.com/alajmo/template-generator template-generator-95: path: ../many/template-generator-95 url: https://github.com/alajmo/template-generator template-generator-96: path: ../many/template-generator-96 url: https://github.com/alajmo/template-generator template-generator-97: path: ../many/template-generator-97 url: https://github.com/alajmo/template-generator template-generator-98: path: ../many/template-generator-98 url: https://github.com/alajmo/template-generator template-generator-99: path: ../many/template-generator-99 url: https://github.com/alajmo/template-generator template-generator-100: path: ../many/template-generator-100 url: https://github.com/alajmo/template-generator template-generator-101: path: ../many/template-generator-101 url: https://github.com/alajmo/template-generator template-generator-102: path: ../many/template-generator-102 url: https://github.com/alajmo/template-generator template-generator-103: path: ../many/template-generator-103 url: https://github.com/alajmo/template-generator template-generator-104: path: ../many/template-generator-104 url: https://github.com/alajmo/template-generator template-generator-105: path: ../many/template-generator-105 url: https://github.com/alajmo/template-generator template-generator-106: path: ../many/template-generator-106 url: https://github.com/alajmo/template-generator template-generator-107: path: ../many/template-generator-107 url: https://github.com/alajmo/template-generator template-generator-108: path: ../many/template-generator-108 url: https://github.com/alajmo/template-generator template-generator-109: path: ../many/template-generator-109 url: https://github.com/alajmo/template-generator template-generator-110: path: ../many/template-generator-110 url: https://github.com/alajmo/template-generator template-generator-111: path: ../many/template-generator-111 url: https://github.com/alajmo/template-generator template-generator-112: path: ../many/template-generator-112 url: https://github.com/alajmo/template-generator template-generator-113: path: ../many/template-generator-113 url: https://github.com/alajmo/template-generator template-generator-114: path: ../many/template-generator-114 url: https://github.com/alajmo/template-generator template-generator-115: path: ../many/template-generator-115 url: https://github.com/alajmo/template-generator template-generator-116: path: ../many/template-generator-116 url: https://github.com/alajmo/template-generator template-generator-117: path: ../many/template-generator-117 url: https://github.com/alajmo/template-generator template-generator-118: path: ../many/template-generator-118 url: https://github.com/alajmo/template-generator template-generator-119: path: ../many/template-generator-119 url: https://github.com/alajmo/template-generator template-generator-120: path: ../many/template-generator-120 url: https://github.com/alajmo/template-generator template-generator-121: path: ../many/template-generator-121 url: https://github.com/alajmo/template-generator template-generator-122: path: ../many/template-generator-122 url: https://github.com/alajmo/template-generator template-generator-123: path: ../many/template-generator-123 url: https://github.com/alajmo/template-generator template-generator-124: path: ../many/template-generator-124 url: https://github.com/alajmo/template-generator template-generator-125: path: ../many/template-generator-125 url: https://github.com/alajmo/template-generator template-generator-126: path: ../many/template-generator-126 url: https://github.com/alajmo/template-generator template-generator-127: path: ../many/template-generator-127 url: https://github.com/alajmo/template-generator template-generator-128: path: ../many/template-generator-128 url: https://github.com/alajmo/template-generator template-generator-129: path: ../many/template-generator-129 url: https://github.com/alajmo/template-generator template-generator-130: path: ../many/template-generator-130 url: https://github.com/alajmo/template-generator template-generator-131: path: ../many/template-generator-131 url: https://github.com/alajmo/template-generator template-generator-132: path: ../many/template-generator-132 url: https://github.com/alajmo/template-generator template-generator-133: path: ../many/template-generator-133 url: https://github.com/alajmo/template-generator template-generator-134: path: ../many/template-generator-134 url: https://github.com/alajmo/template-generator template-generator-135: path: ../many/template-generator-135 url: https://github.com/alajmo/template-generator template-generator-136: path: ../many/template-generator-136 url: https://github.com/alajmo/template-generator template-generator-137: path: ../many/template-generator-137 url: https://github.com/alajmo/template-generator template-generator-138: path: ../many/template-generator-138 url: https://github.com/alajmo/template-generator template-generator-139: path: ../many/template-generator-139 url: https://github.com/alajmo/template-generator template-generator-140: path: ../many/template-generator-140 url: https://github.com/alajmo/template-generator template-generator-141: path: ../many/template-generator-141 url: https://github.com/alajmo/template-generator template-generator-142: path: ../many/template-generator-142 url: https://github.com/alajmo/template-generator template-generator-143: path: ../many/template-generator-143 url: https://github.com/alajmo/template-generator template-generator-144: path: ../many/template-generator-144 url: https://github.com/alajmo/template-generator template-generator-145: path: ../many/template-generator-145 url: https://github.com/alajmo/template-generator template-generator-146: path: ../many/template-generator-146 url: https://github.com/alajmo/template-generator template-generator-147: path: ../many/template-generator-147 url: https://github.com/alajmo/template-generator template-generator-148: path: ../many/template-generator-148 url: https://github.com/alajmo/template-generator template-generator-149: path: ../many/template-generator-149 url: https://github.com/alajmo/template-generator template-generator-150: path: ../many/template-generator-150 url: https://github.com/alajmo/template-generator template-generator-151: path: ../many/template-generator-151 url: https://github.com/alajmo/template-generator template-generator-152: path: ../many/template-generator-152 url: https://github.com/alajmo/template-generator template-generator-153: path: ../many/template-generator-153 url: https://github.com/alajmo/template-generator template-generator-154: path: ../many/template-generator-154 url: https://github.com/alajmo/template-generator template-generator-155: path: ../many/template-generator-155 url: https://github.com/alajmo/template-generator template-generator-156: path: ../many/template-generator-156 url: https://github.com/alajmo/template-generator template-generator-157: path: ../many/template-generator-157 url: https://github.com/alajmo/template-generator template-generator-158: path: ../many/template-generator-158 url: https://github.com/alajmo/template-generator template-generator-159: path: ../many/template-generator-159 url: https://github.com/alajmo/template-generator template-generator-160: path: ../many/template-generator-160 url: https://github.com/alajmo/template-generator template-generator-161: path: ../many/template-generator-161 url: https://github.com/alajmo/template-generator template-generator-162: path: ../many/template-generator-162 url: https://github.com/alajmo/template-generator template-generator-163: path: ../many/template-generator-163 url: https://github.com/alajmo/template-generator template-generator-164: path: ../many/template-generator-164 url: https://github.com/alajmo/template-generator template-generator-165: path: ../many/template-generator-165 url: https://github.com/alajmo/template-generator template-generator-166: path: ../many/template-generator-166 url: https://github.com/alajmo/template-generator template-generator-167: path: ../many/template-generator-167 url: https://github.com/alajmo/template-generator template-generator-168: path: ../many/template-generator-168 url: https://github.com/alajmo/template-generator template-generator-169: path: ../many/template-generator-169 url: https://github.com/alajmo/template-generator template-generator-170: path: ../many/template-generator-170 url: https://github.com/alajmo/template-generator template-generator-171: path: ../many/template-generator-171 url: https://github.com/alajmo/template-generator template-generator-172: path: ../many/template-generator-172 url: https://github.com/alajmo/template-generator template-generator-173: path: ../many/template-generator-173 url: https://github.com/alajmo/template-generator template-generator-174: path: ../many/template-generator-174 url: https://github.com/alajmo/template-generator template-generator-175: path: ../many/template-generator-175 url: https://github.com/alajmo/template-generator template-generator-176: path: ../many/template-generator-176 url: https://github.com/alajmo/template-generator template-generator-177: path: ../many/template-generator-177 url: https://github.com/alajmo/template-generator template-generator-178: path: ../many/template-generator-178 url: https://github.com/alajmo/template-generator template-generator-179: path: ../many/template-generator-179 url: https://github.com/alajmo/template-generator template-generator-180: path: ../many/template-generator-180 url: https://github.com/alajmo/template-generator template-generator-181: path: ../many/template-generator-181 url: https://github.com/alajmo/template-generator template-generator-182: path: ../many/template-generator-182 url: https://github.com/alajmo/template-generator template-generator-183: path: ../many/template-generator-183 url: https://github.com/alajmo/template-generator template-generator-184: path: ../many/template-generator-184 url: https://github.com/alajmo/template-generator template-generator-185: path: ../many/template-generator-185 url: https://github.com/alajmo/template-generator template-generator-186: path: ../many/template-generator-186 url: https://github.com/alajmo/template-generator template-generator-187: path: ../many/template-generator-187 url: https://github.com/alajmo/template-generator template-generator-188: path: ../many/template-generator-188 url: https://github.com/alajmo/template-generator template-generator-189: path: ../many/template-generator-189 url: https://github.com/alajmo/template-generator template-generator-190: path: ../many/template-generator-190 url: https://github.com/alajmo/template-generator template-generator-191: path: ../many/template-generator-191 url: https://github.com/alajmo/template-generator template-generator-192: path: ../many/template-generator-192 url: https://github.com/alajmo/template-generator template-generator-193: path: ../many/template-generator-193 url: https://github.com/alajmo/template-generator template-generator-194: path: ../many/template-generator-194 url: https://github.com/alajmo/template-generator template-generator-195: path: ../many/template-generator-195 url: https://github.com/alajmo/template-generator template-generator-196: path: ../many/template-generator-196 url: https://github.com/alajmo/template-generator template-generator-197: path: ../many/template-generator-197 url: https://github.com/alajmo/template-generator template-generator-198: path: ../many/template-generator-198 url: https://github.com/alajmo/template-generator template-generator-199: path: ../many/template-generator-199 url: https://github.com/alajmo/template-generator template-generator-200: path: ../many/template-generator-200 url: https://github.com/alajmo/template-generator template-generator-201: path: ../many/template-generator-201 url: https://github.com/alajmo/template-generator template-generator-202: path: ../many/template-generator-202 url: https://github.com/alajmo/template-generator template-generator-203: path: ../many/template-generator-203 url: https://github.com/alajmo/template-generator template-generator-204: path: ../many/template-generator-204 url: https://github.com/alajmo/template-generator template-generator-205: path: ../many/template-generator-205 url: https://github.com/alajmo/template-generator template-generator-206: path: ../many/template-generator-206 url: https://github.com/alajmo/template-generator template-generator-207: path: ../many/template-generator-207 url: https://github.com/alajmo/template-generator template-generator-208: path: ../many/template-generator-208 url: https://github.com/alajmo/template-generator template-generator-209: path: ../many/template-generator-209 url: https://github.com/alajmo/template-generator template-generator-210: path: ../many/template-generator-210 url: https://github.com/alajmo/template-generator template-generator-211: path: ../many/template-generator-211 url: https://github.com/alajmo/template-generator template-generator-212: path: ../many/template-generator-212 url: https://github.com/alajmo/template-generator template-generator-213: path: ../many/template-generator-213 url: https://github.com/alajmo/template-generator template-generator-214: path: ../many/template-generator-214 url: https://github.com/alajmo/template-generator template-generator-215: path: ../many/template-generator-215 url: https://github.com/alajmo/template-generator template-generator-216: path: ../many/template-generator-216 url: https://github.com/alajmo/template-generator template-generator-217: path: ../many/template-generator-217 url: https://github.com/alajmo/template-generator template-generator-218: path: ../many/template-generator-218 url: https://github.com/alajmo/template-generator template-generator-219: path: ../many/template-generator-219 url: https://github.com/alajmo/template-generator template-generator-220: path: ../many/template-generator-220 url: https://github.com/alajmo/template-generator template-generator-221: path: ../many/template-generator-221 url: https://github.com/alajmo/template-generator template-generator-222: path: ../many/template-generator-222 url: https://github.com/alajmo/template-generator template-generator-223: path: ../many/template-generator-223 url: https://github.com/alajmo/template-generator template-generator-224: path: ../many/template-generator-224 url: https://github.com/alajmo/template-generator template-generator-225: path: ../many/template-generator-225 url: https://github.com/alajmo/template-generator template-generator-226: path: ../many/template-generator-226 url: https://github.com/alajmo/template-generator template-generator-227: path: ../many/template-generator-227 url: https://github.com/alajmo/template-generator template-generator-228: path: ../many/template-generator-228 url: https://github.com/alajmo/template-generator template-generator-229: path: ../many/template-generator-229 url: https://github.com/alajmo/template-generator template-generator-230: path: ../many/template-generator-230 url: https://github.com/alajmo/template-generator template-generator-231: path: ../many/template-generator-231 url: https://github.com/alajmo/template-generator template-generator-232: path: ../many/template-generator-232 url: https://github.com/alajmo/template-generator template-generator-233: path: ../many/template-generator-233 url: https://github.com/alajmo/template-generator template-generator-234: path: ../many/template-generator-234 url: https://github.com/alajmo/template-generator template-generator-235: path: ../many/template-generator-235 url: https://github.com/alajmo/template-generator template-generator-236: path: ../many/template-generator-236 url: https://github.com/alajmo/template-generator template-generator-237: path: ../many/template-generator-237 url: https://github.com/alajmo/template-generator template-generator-238: path: ../many/template-generator-238 url: https://github.com/alajmo/template-generator template-generator-239: path: ../many/template-generator-239 url: https://github.com/alajmo/template-generator template-generator-240: path: ../many/template-generator-240 url: https://github.com/alajmo/template-generator template-generator-241: path: ../many/template-generator-241 url: https://github.com/alajmo/template-generator template-generator-242: path: ../many/template-generator-242 url: https://github.com/alajmo/template-generator template-generator-243: path: ../many/template-generator-243 url: https://github.com/alajmo/template-generator template-generator-244: path: ../many/template-generator-244 url: https://github.com/alajmo/template-generator template-generator-245: path: ../many/template-generator-245 url: https://github.com/alajmo/template-generator ================================================ FILE: test/playground/imports/projects.yaml ================================================ import: - ./tasks.yaml projects: template-generator: path: ../template-generator url: git@github.com:alajmo/template-generator tags: [cli,bash] ================================================ FILE: test/playground/imports/specs.yaml ================================================ specs: default: output: table parallel: false ignore_errors: false ignore_non_existing: false omit_empty_rows: false advanced: output: table parallel: false ignore_errors: true omit_empty_rows: false table: output: table parallel: false ignore_errors: true omit_empty_rows: false omit_empty_columns: false ================================================ FILE: test/playground/imports/targets.yaml ================================================ targets: default: all: false cwd: true tap-report: projects: [tap-report] all: all: true root: projects: [playground] cwd: true ================================================ FILE: test/playground/imports/tasks.yaml ================================================ tasks: hello: desc: hello world cmd: echo "Hello World" pwd: target: root cmd: pwd ================================================ FILE: test/playground/imports/themes.yaml ================================================ themes: default: tree: style: bold stream: prefix: true header: true header_char: '*' header_prefix: 'TASK' prefix_colors: ['blue', 'red'] table: style: light border: around: true columns: true header: true rows: false advanced: tree: style: light stream: prefix: true header: true header_char: '*' header_prefix: 'TASK' colors: ['blue', 'red'] table: style: ascii border: around: true columns: true header: true rows: true ================================================ FILE: test/playground/mani.yaml ================================================ import: # - ./imports/many-projects.yaml - ./imports/projects.yaml - ./imports/tasks.yaml - ./imports/targets.yaml - ./imports/specs.yaml - ./imports/themes.yaml sync_remotes: true projects: playground: path: . dashgrid: path: frontend/dashgrid url: git+ssh://git@github.com/alajmo/dashgrid.git clone: git clone $MANI_PROJECT_URL $MANI_PROJECT_PATH tags: [frontend, node] remotes: foo: bar env: foo: bar tap-report: path: frontend/tap-report url: https://github.com/alajmo/tap-report tags: [frontend] kaka: path: kaka url: git+ssh://git@github.com/alajmo/dashgrid.git tags: [frontend, node] # GLOBAL ENVS env: VERSION: v0.1.0 GIT: git --no-pager DATE: $(date -u +"%Y-%m-%dT%H:%M:%S%Z") # NO_COLOR: true # TASKS tasks: test: desc: simple test task cmd: echo "$branch_name" spec: ignore_errors: true ignore_non_existing: true env: branch_name: main git-status: git status echo: desc: Print hello world cmd: echo "hello world" ping: desc: ping server cmd: echo pong sleep: desc: Sleep 2 seconds cmd: sleep 2 && echo "slept 2 seconds" status: desc: git status target: tap-report spec: output: text commands: - mcd: git status - cmd: git branch cmd: echo done stream: desc: test text info env: BRANCH: main FOO: bar target: tap-report spec: output: text commands: - task: echo - cmd: echo lala cmd: echo hi table: env: BRANCH: lala FOO: bar target: tap-report spec: output: table commands: - task: echo - cmd: echo lala cmd: echo hi install: desc: npm install cmd: npm install fail: desc: fail on purpose cmd: | echo "FAILED" exit 1 node: target: all shell: node cmd: console.log("Running node.js example") random-data: desc: generate random data cmd: | jot -r 1 echo "RANDOM-DATA" jot -r 2 sleep 2 jot -r 2 sleep 2 many: desc: run many tasks env: BRANCH: lala HELLO: WORLD target: all spec: ignore_errors: true commands: - task: node - task: fail - task: echo - task: random-data - task: ping submarine: desc: Submarine test spec: output: table cmd: echo 0 commands: - name: command-1 cmd: echo 1 - name: command-2 cmd: echo 2 - name: command-3 cmd: echo 3 - task: pwd ================================================ FILE: test/scripts/exec ================================================ #!/bin/bash set -e set -o pipefail APPNAME=mani PROJECT_DIR=$(dirname "$(cd "$(dirname "${0}")"; pwd -P)") function help() { cat >&2 << EOF This script is debugger for mani. Options: --test|-t {case} Run only cases which have specified pattern in the case names --count|-c {count} Run tests multiple times, the clean flag is necessary for this flag --help|-h Show this message Examples: ./test/run.sh EOF } function parse_options() { IMAGE=alpine SHELL=bash while [[ $# -gt 0 ]]; do case "${1}" in --image|-i) IMAGE="${2}" shift && shift ;; --shell|-s) SHELL="${2}" shift && shift ;; --help|-h) help && exit 0 ;; *) printf "Unknown flag: ${1}\n\n" help exit 1 ;; esac done } function exec_docker() { image="${APPNAME}/exec:${IMAGE}" shell= case $SHELL in zsh) shell="/bin/zsh" ;; fish) shell="/usr/bin/fish" ;; ps) shell="/bin/ps" ;; *) shell="/bin/bash" ;; esac docker build \ --file "$PROJECT_DIR/images/$IMAGE.exec.Dockerfile" \ --tag ${image} \ . docker run \ -it --rm \ "$image" \ "$shell" } function __main__() { parse_options $@ exec_docker } __main__ $@ ================================================ FILE: test/scripts/git ================================================ #!/bin/bash # Mock git, used for testing purposes. git() { if [[ $1 == "clone" ]]; then mkdir -p "$4/.git" touch "$4/empty" # elif [[ $1 == "init" ]]; then # mkdir -p "$3/.git" # touch "$3/empty" else /usr/bin/git "$@" fi } git $@ ================================================ FILE: test/scripts/test ================================================ #!/bin/bash set -e set -o pipefail APPNAME=mani PROJECT_DIR=$(dirname "$(cd "$(dirname "${0}")"; pwd -P)") function help() { cat >&2 << EOF This script is used to run tests in docker Options: --run|-r Run only those tests matching the regular expression (wraps the go testflag -run) --count|-c Run each test and benchmark n times (wraps the go testflag -count) --clean Clears the test/tmp directory after each run --build Build docker image --update|-u Update golden files --debug|-d Show stdout of the test commands --help|-h Show this message Examples: ./test ./test --debug --run TestInitCmd EOF } function parse_options() { RUN= COUNT=1 UPDATE_GOLDEN= BUILD= CLEAN= DEBUG= while [[ $# -gt 0 ]]; do case "${1}" in --build|-b) BUILD=YES shift ;; --debug|-d) DEBUG="-debug" shift ;; --clean) CLEAN="-clean" shift ;; --run|-r) RUN="-run=${2}" shift && shift ;; --count|-c) COUNT="${2}" shift && shift ;; --update|-u) UPDATE_GOLDEN="-update" shift ;; --help|-h) help && exit 0 ;; *) printf "Unknown flag: ${1}\n\n" help exit 1 ;; esac done } function run_tests() { if [[ "$COUNT" -gt 1 ]]; then CLEAN="-clean" fi for runtime in `ls ${PROJECT_DIR}/images/*test.Dockerfile`; do testcase=`basename ${runtime} | sed -e s/\.test\.Dockerfile$//` image="${APPNAME}/test:${testcase}" local image_found=$(docker image inspect "$image" >/dev/null 2>&1 && echo yes) if [[ "$BUILD" || -n "$UPDATE_GOLDEN" || -z "$image_found" ]]; then # Build test images for dockerfile in `ls ${PROJECT_DIR}/images/*.test.Dockerfile`; do testcase=`basename ${dockerfile} | sed -e s/\.test\.Dockerfile$//` echo "┌───────────── ${testcase}" echo "│ [Docker] Building image..." docker build \ --file ${dockerfile} \ --tag "$image" \ . | \ sed "s/^/│ /" echo "└───────────── ${testcase} [OK]" done fi echo "┌───────────── ${testcase}" echo "│ [Docker] Running tests..." docker run \ -t \ --user "$(id -u):$(id -g)" \ --volume "$PWD:/home/test" \ --volume "$(go env GOCACHE):/go/cache" \ "$image" \ /bin/sh -c "go test -v ./test/... $RUN -count=${COUNT} $CLEAN $DEBUG $UPDATE_GOLDEN" | \ sed "s/^/│ [${testcase}] /" echo "└───────────── ${testcase} [OK]" done } function __main__() { parse_options $@ run_tests } __main__ $@