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
`mani` lets you manage multiple repositories and run commands across them.

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

## 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
---
[31merror[0m: 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
---
[31merror[0m: 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
---
[31merror[0m: 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__ $@