From 28c507e04bd5c0de7f88eac17c6995ee35b6d3c9 Mon Sep 17 00:00:00 2001 From: Ihsan Tonuzi <115842560+iton0@users.noreply.github.com> Date: Wed, 20 Nov 2024 01:51:47 -0500 Subject: [PATCH] feat: add template subcommand (#24) - Created `template` subcommand to manage reusable Git hook templates. - Implemented `create`, `copy`, `edit`, and `remove` subcommands for template management. - Added the ability to configure Git hook templates for multiple use cases. feat: add config subcommand - Created `config` subcommand to manage HkUp configuration settings. - Subcommands include `get` and `set` for managing configuration settings. - NOTE: This feature is currently hidden and will be available once configuration settings are finalized. chore: improve install script - Improved script to handle both installation and update operations when the version is lower than the release version. chore: update release-please GitHub action - Moved logic from `scripts/build` to `release-please` action. - Removed `scripts/build` to simplify release process. chore: use defaults for Go dependency checking - Removed settings for day, time, and timezone from Go dependency checking. - Dependabot now uses the default weekly time settings for Go dependencies. chore: fix pre-commit git hook - Removed adding all files after running the Go formatter, fixing the issue where unwanted files were being added to commits. refactor(cmd): simplify flag handling & init logic - Renamed flags for consistency: `Lang` to `LangFlg`, `GitDir` to `GitDirFlg`, `WorkTree` to `WorkTreeFlg`. - Consolidated command registration into `root.go`. - Simplified the `init()` function and centralized logic. refactor(logic): update flags and simplify hook commands - Simplified Add, Remove, and Init logic by centralizing directory checks. - Renamed flags: `Lang` to `LangFlg`, `GitDir` to `GitDirFlg`, `WorkTree` to `WorkTreeFlg`. - Improved readability and error handling in file operations. refactor(git): improve comments and simplify hook logic - Updated comments for clarity and consistency in the Git package. - Simplified `GetHook` and `GetLang` functions to improve readability. - Updated the `supportedLangs` map to explicitly include `sh` and `bash`. feat(util): add file ops, prompts, and config functions - Added utility functions for terminal prompts: `YesNoPrompt` and `UserInputPrompt`. - Expanded file handling functions: `CreateDirectory` and `CopyFile`. - Introduced functions to handle HkUp configuration paths: `GetConfigDirPath`, `GetConfigFilePath`, `GetTemplateDirPath`. - Added functions for TOML file manipulation: `GetTOMLValue`, `SetTOMLValue`. - Implemented `GetEditor` function to determine the default editor based on configuration, git, or environment variables. --- .github/dependabot.yml | 3 - .github/workflows/release-please.yml | 34 ++- .hkup/pre-commit | 3 - README.md | 84 ++++--- cmd/add.go | 3 +- cmd/config/get.go | 17 ++ cmd/config/main.go | 9 + cmd/config/root.go | 19 ++ cmd/config/set.go | 17 ++ cmd/doc.go | 4 +- cmd/init.go | 7 +- cmd/list.go | 4 +- cmd/main.go | 4 +- cmd/remove.go | 4 +- cmd/root.go | 24 +- cmd/template/copy.go | 17 ++ cmd/template/create.go | 28 +++ cmd/template/edit.go | 17 ++ cmd/template/main.go | 9 + cmd/template/remove.go | 18 ++ cmd/template/root.go | 21 ++ internal/git/main.go | 44 ++-- internal/logic/add.go | 63 +++--- internal/logic/config/get.go | 19 ++ internal/logic/config/main.go | 13 ++ internal/logic/config/set.go | 15 ++ internal/logic/doc.go | 32 +-- internal/logic/init.go | 66 +++--- internal/logic/list.go | 17 +- internal/logic/main.go | 18 +- internal/logic/remove.go | 32 +-- internal/logic/template/copy.go | 107 +++++++++ internal/logic/template/create.go | 324 +++++++++++++++++++++++++++ internal/logic/template/edit.go | 58 +++++ internal/logic/template/main.go | 15 ++ internal/logic/template/remove.go | 34 +++ internal/util/main.go | 298 +++++++++++++++++++++--- scripts/build | 7 - scripts/install | 48 +++- 39 files changed, 1319 insertions(+), 237 deletions(-) create mode 100644 cmd/config/get.go create mode 100644 cmd/config/main.go create mode 100644 cmd/config/root.go create mode 100644 cmd/config/set.go create mode 100644 cmd/template/copy.go create mode 100644 cmd/template/create.go create mode 100644 cmd/template/edit.go create mode 100644 cmd/template/main.go create mode 100644 cmd/template/remove.go create mode 100644 cmd/template/root.go create mode 100644 internal/logic/config/get.go create mode 100644 internal/logic/config/main.go create mode 100644 internal/logic/config/set.go create mode 100644 internal/logic/template/copy.go create mode 100644 internal/logic/template/create.go create mode 100644 internal/logic/template/edit.go create mode 100644 internal/logic/template/main.go create mode 100644 internal/logic/template/remove.go delete mode 100755 scripts/build diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6a9b479..e7d7ad6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,9 +4,6 @@ updates: directory: "/" schedule: interval: "weekly" - day: "friday" - time: "12:00" - timezone: "America/New_York" - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 86b8b12..ebe7280 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -17,12 +17,11 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - # Step 2: Run your build script - - name: Run build script - run: | - rm -rf ./bin - chmod +x ./scripts/build - ./scripts/build + # Step 2: Set up Go environment + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' # Step 3: Use release-please to create a release - name: Create release @@ -31,12 +30,29 @@ jobs: with: release-type: go - # Step 4: Upload Release Artifacts if a release was created + # Step 4: Update the version in the build and perform multi-platform builds + - name: Build for multiple platforms + if: ${{ steps.release.outputs.release_created }} + run: | + VERSION="${{ steps.release.outputs.version }}" + echo "Updating version to ${VERSION}" + + mkdir -p bin + + # Build for Linux + GOOS=linux GOARCH=amd64 go build -o bin/hkup-linux -ldflags="-s -w -X cmd.version=${VERSION}" . + + # Build for Darwin/macOS + GOOS=darwin GOARCH=amd64 go build -o bin/hkup-darwin -ldflags="-s -w -X cmd.version=${VERSION}" . + + # Build for Windows (optional, uncomment if needed) + # GOOS=windows GOARCH=amd64 go build -o bin/hkup.exe -ldflags="-s -w -X cmd.version=${VERSION}" . + + # Step 5: Upload Release Artifacts if a release was created - name: Upload Release Artifacts if: ${{ steps.release.outputs.release_created }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release upload ${{ steps.release.outputs.tag_name }} \ - ./bin/* - + bin/* diff --git a/.hkup/pre-commit b/.hkup/pre-commit index e4f22e3..1e1627e 100755 --- a/.hkup/pre-commit +++ b/.hkup/pre-commit @@ -4,10 +4,7 @@ if git diff --cached --name-only | grep -q '\.go$'; then echo "Formatting Go files..." gofmt -w . - echo "" - # Add formatted files to the staging area - git add . else echo "No Go files changed. Skipping formatting." echo "" diff --git a/README.md b/README.md index 61d5da3..2bc8c8e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # HkUp -Your CLI tool with benefits built by [iton0](https://github.com/iton0) in [Go](https://go.dev/)! +> Your CLI tool with benefits built by [iton0](https://github.com/iton0) in [Go](https://go.dev/)! [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/iton0/hkup-cli)](https://github.com/iton0/hkup-cli/releases/latest) [![godoc](https://godoc.org/github.com/iton0/hkup-cli?status.svg)](http://godoc.org/github.com/iton0/hkup-cli) @@ -11,9 +11,16 @@ Your CLI tool with benefits built by [iton0](https://github.com/iton0) in [Go](h ## Introduction Git hooks automate and implement processes in your workflow, increasing code quality and consistency. -However, many developers avoid git hooks due to a lack of awareness and the perceived complexity of setup, discouraging them from using this feature. +Common use cases include: +- Commit Message Validation +- Environment Configuration +- Formatting +- Linting +- Testing -**HkUp** simplifies the management of git hooks, allowing you to focus on the logic and usage of your hooks instead. +However, many developers avoid Git hooks due to a lack of awareness and the perceived complexity of setup, discouraging them from using this feature. + +**HkUp** simplifies the management of Git hooks, allowing you to focus on the logic and usage of your hooks instead. ## Installation External Dependencies: @@ -27,10 +34,9 @@ Run the script below (supports Linux and macOS): curl -sSL https://raw.githubusercontent.com/iton0/hkup-cli/main/scripts/install | sh ``` > [!Tip] -> To update HkUp, rerun the above script. -> It will replace the current version. +> To update HkUp, simply rerun the script above. It will automatically replace your current version with the latest release. -#### Uninstalling hkup +### Uninstalling HkUp ```sh # Locates and deletes the HkUp binary @@ -40,49 +46,63 @@ sh -c 'rm "$(command -v 'hkup')"' ## Usage Quickstart -This section provides basic information about core usage. For detailed options run `hkup --help`. +This section provides basic information about core usage. For detailed usage information run `hkup --help`. -#### Initializing hkup +### Initializing hkup Run the following command in your git repository to initialize HkUp: ```sh hkup init ``` -This command creates a **.hkup** folder and sets the local **core.hooksPath** variable. If the folder already exists, it will simply update the path variable. The path is relative, ensuring that moving your repository won’t affect hook sourcing. +This creates a **.hkup** directory and sets the local **core.hooksPath** variable. If the directory already exists, it will simply update the path variable. The path is relative, ensuring that moving your repository won’t affect hook sourcing. -#### Adding & Removing hooks +### Adding & Removing hooks Add or remove hooks easily with: ```sh hkup add + hkup remove ``` -#### Info & Docs -There are two commands that will help you with both HkUp and git hooks: - -**`hkup list {hook|lang}`** -Outputs list of either available hooks or supported languages. +### Templates +A **template** is a pre-configured, reusable Git hook that simplifies and automates the process of setting up hooks in a Git repository. With **HkUp**, you can create, copy, edit, or remove templates, allowing for consistent and easy application of hooks without needing to rewrite scripts each time. -**`hkup doc `** -Opens your browser with Git documentation for the specified git hook, helping you understand its usage. +The templates are stored in the HkUp config templates directory that can either be found at **$XDG_CONFIG_HOME/hkup/templates** or **$HOME/.config/hkup/templates** depending on your system. -## Future TODOs -- [ ] make an update subcommand -- [ ] store custom git hooks as templates for future use (via add template subcmd) - - Allow users to create, store, and share templates for common hooks. Users can fetch these templates over the network. -- [ ] branch-specific hooks -- [ ] logo maybe? +#### Naming Convention +Template files follow the naming convention: +`#` +Where: +- `` is the name of the template. +- `` is the specific Git hook (e.g., pre-commit, post-merge). -## Contributing -HkUp welcomes contributions to enhance this CLI application! Before submitting a pull request (PR) for a new feature, please follow these steps: +**Create a template**: +```sh +hkup template create +# OR +hkup template create +``` -1. **Create an Issue**: - If you have an idea for a new feature, please create a new issue in the repository using the **feature_request** template. Provide a clear description of the feature and its potential benefits. Please note that issues submitted without using the template may be closed without warning. +**Copy a template** into current working directory: +```sh +hkup template copy +``` -2. **Wait for Approval**: - Once you submit your issue, I’ll review it and provide feedback. If I approve the feature request, I will let you know that you're free to proceed with your PR. +**Edit a template**: +```sh +hkup template edit +``` +>[!CAUTION] +> Editing a template will not update its copies. -3. **Submit Your PR**: - After receiving approval, you can create your PR. Be sure to reference the issue in your PR description. +**Remove a template**: +```sh +hkup template remove +``` -Please note that PRs submitted without prior approval through an issue may be closed without merging. This process helps us manage feature requests effectively and ensures that contributions align with the project’s goals. +## Roadmap to v1.0.0 +1. windows support +2. wrapper for git init & clone and gh repo create & clone +3. HkUp logo (may or may not keep this one) +4. better test coverage +5. Allow users to create, store, and share templates. Users can fetch these templates over internet (may need to make another repo for this). diff --git a/cmd/add.go b/cmd/add.go index a1d5586..d1e3765 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -18,6 +18,5 @@ var ( ) func init() { - addCmd.Flags().StringVar(&logic.Lang, "lang", "", "supported languages for git hooks") - rootCmd.AddCommand(addCmd) + addCmd.Flags().StringVar(&logic.LangFlg, "lang", "", "supported languages for git hooks") } diff --git a/cmd/config/get.go b/cmd/config/get.go new file mode 100644 index 0000000..4492cfe --- /dev/null +++ b/cmd/config/get.go @@ -0,0 +1,17 @@ +package config + +import ( + "github.com/iton0/hkup-cli/internal/logic/config" + "github.com/spf13/cobra" +) + +var ( + getCmd = &cobra.Command{ + Use: "get ", + Short: "Get a HkUp config setting", + Args: cobra.ExactArgs(1), + RunE: config.Get, + } +) + +func init() {} diff --git a/cmd/config/main.go b/cmd/config/main.go new file mode 100644 index 0000000..ebe9b89 --- /dev/null +++ b/cmd/config/main.go @@ -0,0 +1,9 @@ +/* +Package template initializes the config subcommand and its subcommands. + +This package is utilized in the root command of [github.com/iton0/hkup-cli/cmd] +package. +*/ +package config + +// NOTE: This file is for documentation purposes and should be kept empty. diff --git a/cmd/config/root.go b/cmd/config/root.go new file mode 100644 index 0000000..2fdb099 --- /dev/null +++ b/cmd/config/root.go @@ -0,0 +1,19 @@ +package config + +import ( + "github.com/spf13/cobra" +) + +var ( + // RootCmd is the config command that will be added to the root HkUp command. + RootCmd = &cobra.Command{ + Use: "config", + Short: "HkUp configuration settings", + Hidden: true, // TODO: remove after finalizing configuration settings + } +) + +func init() { + RootCmd.AddCommand(getCmd) + RootCmd.AddCommand(setCmd) +} diff --git a/cmd/config/set.go b/cmd/config/set.go new file mode 100644 index 0000000..8f7b6f6 --- /dev/null +++ b/cmd/config/set.go @@ -0,0 +1,17 @@ +package config + +import ( + "github.com/iton0/hkup-cli/internal/logic/config" + "github.com/spf13/cobra" +) + +var ( + setCmd = &cobra.Command{ + Use: "set ", + Short: "Set a HkUp config setting", + Args: cobra.ExactArgs(2), + RunE: config.Set, + } +) + +func init() {} diff --git a/cmd/doc.go b/cmd/doc.go index f2c8a83..cd2a9c2 100644 --- a/cmd/doc.go +++ b/cmd/doc.go @@ -18,6 +18,4 @@ var ( } ) -func init() { - rootCmd.AddCommand(docCmd) -} +func init() {} diff --git a/cmd/init.go b/cmd/init.go index 2c3b8e0..7a1548b 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -9,15 +9,14 @@ var ( initCmd = &cobra.Command{ Use: "init", Short: "Initialize hkup", - Long: "Create an empty hkup folder or reinitialize an existing one", + Long: "Create an empty hkup directory or reinitialize an existing one", Args: cobra.NoArgs, RunE: logic.Init, } ) func init() { - initCmd.Flags().StringVar(&logic.GitDir, "gitdir", "", "specified path to git directory") - initCmd.Flags().StringVar(&logic.WorkTree, "worktree", "", "specified path to working tree") + initCmd.Flags().StringVar(&logic.GitDirFlg, "gitdir", "", "specified path to git directory") + initCmd.Flags().StringVar(&logic.WorkTreeFlg, "worktree", "", "specified path to working tree") initCmd.MarkFlagsRequiredTogether("gitdir", "worktree") - rootCmd.AddCommand(initCmd) } diff --git a/cmd/list.go b/cmd/list.go index ab8abf3..71724d0 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -16,6 +16,4 @@ var ( } ) -func init() { - rootCmd.AddCommand(listCmd) -} +func init() {} diff --git a/cmd/main.go b/cmd/main.go index ee9fc84..00e8a88 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,8 +1,6 @@ /* Package cmd initializes all commands (including root command) for the HkUp CLI. - -Additionally, the package holds all tests for commands. */ package cmd -// Note: This file should be kept empty. +// NOTE: This file is for documentation purposes and should be kept empty. diff --git a/cmd/remove.go b/cmd/remove.go index 92f6fcb..580ff73 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -18,6 +18,4 @@ var ( } ) -func init() { - rootCmd.AddCommand(removeCmd) -} +func init() {} diff --git a/cmd/root.go b/cmd/root.go index 85a508f..9f05a57 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,23 +1,39 @@ package cmd import ( + "github.com/iton0/hkup-cli/cmd/config" + "github.com/iton0/hkup-cli/cmd/template" "github.com/spf13/cobra" ) var ( + // version holds the centralized version of HkUp. + // It is updated to the latest release version at build time of the binaries. + // + // INFO: look at the .github/workflows/release-please.yml to view how version + // is updated. + version = "dev" + rootCmd = &cobra.Command{ Use: "hkup", Short: "hkup CLI", Long: `hkup is a management tool for git hooks`, - Args: cobra.MinimumNArgs(1), - Version: "0.2.1", + Version: version, } ) -func init() {} +func init() { + rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(addCmd) + rootCmd.AddCommand(removeCmd) + rootCmd.AddCommand(template.RootCmd) + rootCmd.AddCommand(config.RootCmd) + rootCmd.AddCommand(docCmd) + rootCmd.AddCommand(listCmd) +} // Execute serves as a wrapper for the Cobra API's Execute function, -// allowing it to be called from the main package. +// allowing it to be called from the [github.com/iton0/hkup-cli] package. func Execute() { rootCmd.Execute() } diff --git a/cmd/template/copy.go b/cmd/template/copy.go new file mode 100644 index 0000000..95be23b --- /dev/null +++ b/cmd/template/copy.go @@ -0,0 +1,17 @@ +package template + +import ( + "github.com/iton0/hkup-cli/internal/logic/template" + "github.com/spf13/cobra" +) + +var ( + copyCmd = &cobra.Command{ + Use: "copy ", + Short: "Copy a git hook template", + Args: cobra.ExactArgs(1), + RunE: template.Copy, + } +) + +func init() {} diff --git a/cmd/template/create.go b/cmd/template/create.go new file mode 100644 index 0000000..7f9c72f --- /dev/null +++ b/cmd/template/create.go @@ -0,0 +1,28 @@ +package template + +import ( + "github.com/iton0/hkup-cli/internal/git" + "github.com/iton0/hkup-cli/internal/logic/template" + "github.com/iton0/hkup-cli/internal/util" + "github.com/spf13/cobra" +) + +var ( + createCmd = &cobra.Command{ + Use: "create []", + Short: "Create a git hook template", + ValidArgs: util.ConvertMapKeysToSlice(git.Hooks()), + Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs), + RunE: template.Create, + } +) + +func init() { + createCmd.Flags().StringVar(&template.TemplateLangFlg, "lang", "", "supported languages for git hooks") + createCmd.Flags().StringVar(&template.TemplateNameFlg, "name", "", "specified name for git hook template") + createCmd.Flags().BoolVar(&template.TemplateCwdFlg, "cwd", false, "use hook from current working directory") + createCmd.Flags().BoolVar(&template.TemplateCopyFlg, "copy", false, "copy to current working directory") + createCmd.Flags().BoolVar(&template.TemplateEditFlg, "edit", false, "open template in editor") + createCmd.MarkFlagsMutuallyExclusive("cwd", "lang") + createCmd.MarkFlagsMutuallyExclusive("cwd", "copy") +} diff --git a/cmd/template/edit.go b/cmd/template/edit.go new file mode 100644 index 0000000..5ef363c --- /dev/null +++ b/cmd/template/edit.go @@ -0,0 +1,17 @@ +package template + +import ( + "github.com/iton0/hkup-cli/internal/logic/template" + "github.com/spf13/cobra" +) + +var ( + editCmd = &cobra.Command{ + Use: "edit ", + Short: "Edit a git hook template", + Args: cobra.ExactArgs(1), + RunE: template.Edit, + } +) + +func init() {} diff --git a/cmd/template/main.go b/cmd/template/main.go new file mode 100644 index 0000000..7d05225 --- /dev/null +++ b/cmd/template/main.go @@ -0,0 +1,9 @@ +/* +Package template initializes the template subcommand and its subcommands. + +This package is utilized in the root command of [github.com/iton0/hkup-cli/cmd] +package. +*/ +package template + +// NOTE: This file is for documentation purposes and should be kept empty. diff --git a/cmd/template/remove.go b/cmd/template/remove.go new file mode 100644 index 0000000..6445ab0 --- /dev/null +++ b/cmd/template/remove.go @@ -0,0 +1,18 @@ +package template + +import ( + "github.com/iton0/hkup-cli/internal/logic/template" + "github.com/spf13/cobra" +) + +var ( + removeCmd = &cobra.Command{ + Use: "remove ", + Aliases: []string{"rm"}, + Short: "Remove a git hook template", + Args: cobra.ExactArgs(1), + RunE: template.Remove, + } +) + +func init() {} diff --git a/cmd/template/root.go b/cmd/template/root.go new file mode 100644 index 0000000..8bcfc3f --- /dev/null +++ b/cmd/template/root.go @@ -0,0 +1,21 @@ +package template + +import ( + "github.com/spf13/cobra" +) + +var ( + // RootCmd is the template command that will be added to the root HkUp command. + RootCmd = &cobra.Command{ + Use: "template", + Short: "Reusable Git hook", + Long: "A template refers to a pre-configured, reusable Git hook that can be easily applied\nto a Git repository. The main goal of a template is to simplify and automate the setup\nof these hooks, making it easy to apply them consistently without having to \nwrite or configure the scripts from scratch each time.", + } +) + +func init() { + RootCmd.AddCommand(createCmd) + RootCmd.AddCommand(copyCmd) + RootCmd.AddCommand(editCmd) + RootCmd.AddCommand(removeCmd) +} diff --git a/internal/git/main.go b/internal/git/main.go index a5a0963..0a6576c 100644 --- a/internal/git/main.go +++ b/internal/git/main.go @@ -1,9 +1,12 @@ /* -Package git provides utilities related to Git hooks and supported scripting languages for those hooks. +Package git provides utilities related to Git hooks and supported scripting +languages for those hooks. -This package includes functionality for retrieving information about Git hooks and determining the supported languages for writing hooks. +This package includes functionality for retrieving information about Git hooks +and determining the supported languages for writing hooks. -This package is designed to facilitate the use and understanding of Git hooks in various programming environments. +This package is designed to facilitate the use and understanding of Git hooks +in various programming environments. */ package git @@ -11,12 +14,12 @@ import ( "fmt" ) -// HookDocSite is a constant of the base URL for the Git hooks documentation. -const HookDocSite = "https://git-scm.com/docs/githooks#" - var ( - // hooks is a map of Git hook names to their corresponding sections in the Git hooks documentation. This map is kept up to date as of 10/24/2024. - // source: https://git-scm.com/docs/githooks + // hooks is a map of Git hook names to their respective section of the + // Git hooks documentation site. + // + // INFO: This map is up to date as of 11/19/2024. + // Source: https://git-scm.com/docs/githooks hooks = map[string]string{ "applypatch-msg": "_applypatch_msg", "pre-applypatch": "_pre_applypatch", @@ -48,8 +51,11 @@ var ( "post-index-change": "_post_index_change", } - // supportedLangs is a map indicating which programming languages are supported for Git hooks, excluding the default bash. + // supportedLangs is a map indicating which programming languages are supported + // for Git hooks. supportedLangs = map[string]bool{ + "sh": true, + "bash": true, "python": true, "ruby": true, "node": true, @@ -58,13 +64,14 @@ var ( } ) -// GetHook retrieves the URL section of the documentation for a specified Git hook. Returns an error if the hook is not found. +// GetHook retrieves URL section of git doc site for specified Git hook. +// Returns URL section of specified hook and error if the hook is not supported. func GetHook(key string) (string, error) { - value, exists := hooks[key] - if !exists { - return "", fmt.Errorf("hook not found: %s", key) + if val, exist := hooks[key]; !exist { + return "", fmt.Errorf("hook not supported: %s", key) + } else { + return val, nil } - return value, nil } // Hooks returns the complete map of all defined Git hooks. @@ -72,13 +79,14 @@ func Hooks() map[string]string { return hooks } -// GetLang reports if a specified language is supported for Git hooks. Returns an error if the language is not recognized. +// GetLang reports if a specified language is supported for Git hooks. +// Returns boolean and error if the language is not recognized. func GetLang(key string) (bool, error) { - value, exists := supportedLangs[key] - if !exists { + if _, exist := supportedLangs[key]; !exist { return false, fmt.Errorf("language not supported: %s", key) + } else { + return true, nil } - return value, nil } // SupportedLangs returns the map of supported programming languages for Git hooks. diff --git a/internal/logic/add.go b/internal/logic/add.go index 171f503..2bc9ed4 100644 --- a/internal/logic/add.go +++ b/internal/logic/add.go @@ -2,7 +2,6 @@ package logic import ( "fmt" - "os" "path/filepath" "github.com/iton0/hkup-cli/internal/git" @@ -11,55 +10,53 @@ import ( ) var ( - // Lang is an optional flag indicating the programming language to use for the hook script. Defaults to sh. - Lang string + // LangFlg is an optional flag indicating the programming language to use for + // the hook script. + LangFlg string ) -// Add adds a new Git hook with the specified name and optional programming language. -// It creates a new file in the designated `.hkup` directory, setting the appropriate shebang line based on the provided language. -// Returns an error if any of the steps fail, including directory existence, file creation, or permission setting. +// Add creates a new Git hook with the specified git hook name and optional +// programming language in the designated .hkup directory. +// +// Returns error if any of the steps fail above. func Add(cmd *cobra.Command, args []string) error { - var sheBangLine = "#!/bin/sh\n\n\n\n\n" - hook := args[0] - - if Lang != "" { - if _, err := git.GetLang(Lang); err != nil { - return err - } - sheBangLine = fmt.Sprintf("#!/usr/bin/env %s\n\n\n\n\n", Lang) + // Makes sure .hkup directory exists in current working directory + if !util.DoesDirectoryExist(util.HkupDirName) { + return fmt.Errorf("failed running \"hkup add\"\n%s does not exist", util.HkupDirName) } - if !util.DoesDirectoryExist(FullPath) { - return fmt.Errorf("failed running \"hkup add\"\n%s does not exist", FullPath) - } - - filePath := filepath.Join(FullPath, hook) + hook := args[0] + filePath := filepath.Join(util.HkupDirName, hook) + // Does not add if hook already exists in .hkup directory if util.DoesFileExist(filePath) { return fmt.Errorf("%s hook already exists", hook) } - file, err := os.Create(filePath) - if err != nil { - return err - } + var fileContent string - defer func(file *os.File) { - err := file.Close() - if err != nil { - panic(err) + // Uses the specified language from lang flag; else default to sh + if LangFlg != "" { + // make sure lang is supported + if _, err := git.GetLang(LangFlg); err != nil { + return err } - }(file) + fileContent = fmt.Sprintf("#!/usr/bin/env %s\n\n\n\n\n", LangFlg) + } else { + fileContent = "#!/bin/sh\n\n\n\n\n" + } - _, err = file.WriteString(sheBangLine) + file, err := util.CreateFile(filePath) if err != nil { - return fmt.Errorf("failed writing to file: %w", err) + return err } + defer file.Close() - err = os.Chmod(filePath, 0755) + _, err = file.WriteString(fileContent) if err != nil { - return fmt.Errorf("failed changing permissions of file: %w", err) + return err } - return nil + // Either changes the create file's permissions successful or returns error + return util.MakeExecutable(filePath) } diff --git a/internal/logic/config/get.go b/internal/logic/config/get.go new file mode 100644 index 0000000..2ee0112 --- /dev/null +++ b/internal/logic/config/get.go @@ -0,0 +1,19 @@ +package config + +import ( + "github.com/iton0/hkup-cli/internal/util" + "github.com/spf13/cobra" +) + +// Get prints out the value of a specified configuration setting. +// +// Returns error if issue with getting the value. +func Get(cmd *cobra.Command, args []string) error { + out, err := util.GetTOMLValue(util.GetConfigFilePath(), args[0]) + if err != nil { + return err + } + + cmd.Println(out) // This may be empty if the value is not set but key is valid + return nil +} diff --git a/internal/logic/config/main.go b/internal/logic/config/main.go new file mode 100644 index 0000000..2e2ca05 --- /dev/null +++ b/internal/logic/config/main.go @@ -0,0 +1,13 @@ +/* +Package template provides functionality for getting and setting HkUp config settings. +This package utilizes the cobra library for command-line interaction and is +implemented in the respective commands of the +[github.com/iton0/hkup-cli/cmd/config] package. + +Command: + - Set: Updates HkUp config setting. + - Get: Prints HkUp config setting to terminal. +*/ +package config + +// NOTE: This file is for documentation purposes and should be kept empty. diff --git a/internal/logic/config/set.go b/internal/logic/config/set.go new file mode 100644 index 0000000..154f07f --- /dev/null +++ b/internal/logic/config/set.go @@ -0,0 +1,15 @@ +package config + +import ( + "github.com/iton0/hkup-cli/internal/util" + "github.com/spf13/cobra" +) + +// Set updates a configuration setting with a new value. +// +// Returns error if issue with settings the configuration setting. +func Set(cmd *cobra.Command, args []string) error { + // Either setting configuration setting is successful and returns nil or + // returns error + return util.SetTOMLValue(util.GetConfigFilePath(), args[0], args[1]) +} diff --git a/internal/logic/doc.go b/internal/logic/doc.go index ae6321e..f2c9041 100644 --- a/internal/logic/doc.go +++ b/internal/logic/doc.go @@ -11,33 +11,39 @@ import ( // Doc opens the documentation for a specified Git hook in the default web browser. // The command takes a single argument, which is the key (name) of the hook. -// It constructs the URL for the documentation based on the hook name and attempts to open it using the appropriate command for the operating system. +// It constructs the URL for the documentation based on the hook name and attempts +// to open it using the appropriate command for the operating system. // // Returns: -// - error: Returns an error if the hook key is invalid, if the platform is unsupported, or if there is an issue starting the command; otherwise, it returns nil. +// - error if the hook key is invalid, if the platform is unsupported, or if +// there is an issue starting the command. func Doc(cmd *cobra.Command, args []string) error { - key := args[0] - var termCmd *exec.Cmd - var url string - - if hook, err := git.GetHook(key); err != nil { + // Checks if the key exists and returns the url portion to add + // to the git doc site base + hook, err := git.GetHook(args[0]) + if err != nil { return err - } else { - url = git.HookDocSite + hook } + url := "https://git-scm.com/docs/githooks#" + hook // Full url path for the specified git hook + var termCmd *exec.Cmd + switch runtime.GOOS { case "linux": termCmd = exec.Command("xdg-open", url) case "darwin": termCmd = exec.Command("open", url) - case "windows": - termCmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + // case "windows": + // termCmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) default: return fmt.Errorf("unsupported platform: %s", runtime.GOOS) } - termCmd.Start() + err = termCmd.Start() + if err != nil { + return err + } - return termCmd.Wait() + // Must be called after successfully starting terminal command above + return termCmd.Wait() // Returns error if command fails } diff --git a/internal/logic/init.go b/internal/logic/init.go index a9c9e71..f1913c3 100644 --- a/internal/logic/init.go +++ b/internal/logic/init.go @@ -3,62 +3,58 @@ package logic import ( "fmt" "os/exec" - "path/filepath" "github.com/iton0/hkup-cli/internal/util" "github.com/spf13/cobra" ) var ( - // FullPath defines the local repository folder name to hold Git hooks via a - // relative path. - // It is treated as a constant and points to the ".hkup" directory within the - // current working directory. - FullPath = filepath.Join(".", ".hkup") - - // gitCmd defines the terminal command to use to initialize the hkup folder. - gitCmd = []string{"config", "--local", "core.hooksPath", FullPath} - - // GitDir is an optional flag that defines the location of the git directory. + // GitDirFlg is an optional flag that defines the location of the git directory. // Can be useful for bare repos or custom git setups where git directory and // working directory are not in the same location. - GitDir string + GitDirFlg string - // WorkTree is an optional flag that defines the location of the working tree + // WorkTreeFlg is an optional flag that defines the location of the working tree // of a local git repository. - WorkTree string + WorkTreeFlg string ) -// Init initializes the hkup folder for storing Git hooks in the current repository. -// It checks if there the git directory and worktree flags are provided. -// Else it will check if the current working directory is a Git repository, creates -// the hkup folder if it doesn't exist, and sets the Git configuration for -// `core.hooksPath` to point to the hkup folder. -// Returns an error if the current directory is not a Git repository, if the folder -// creation fails, or if there is an issue setting the Git hooks path. +// Init sets the .hkup directory for storing Git hooks in the current repository. // -// Returns: -// - error: Returns an error if any of the steps fail; otherwise, it returns nil. +// Returns error if: +// - current working directory is not a git repo +// - issue with creating .hkup directory +// - hooksPath is already set +// - issue with setting the hooksPath func Init(cmd *cobra.Command, args []string) error { - if GitDir != "" && WorkTree != "" { - gitCmd = []string{"--git-dir=" + GitDir, "--work-tree=" + WorkTree, "config", "--local", "core.hooksPath", FullPath} - } else if err := exec.Command("git", "-C", ".", "rev-parse", "--is-inside-work-tree").Run(); err != nil { - return fmt.Errorf("failed to check if current working directory is git repo: %w", err) + // Only runs if current working directory is git repo + err := exec.Command("git", "-C", ".", "rev-parse", "--is-inside-work-tree").Run() + if err != nil { + return err } - if !util.DoesDirectoryExist(FullPath) { - if err := util.CreateFolder(FullPath); err != nil { + // Tries to create the .hkup directory if it does not exist + if !util.DoesDirectoryExist(util.HkupDirName) { + if err = util.CreateDirectory(util.HkupDirName); err != nil { return err } - cmd.Printf("Initialized hkup folder at %s\n", FullPath) + + cmd.Printf("Initialized hkup directory at %s\n", util.HkupDirName) } - if out, _ := exec.Command("git", "config", "--local", "core.hooksPath").Output(); len(out) != 0 { + // Does not override the hooksPath variable if already set + if out, _ := exec.Command("git", "config", "--local", "core.hooksPath").CombinedOutput(); len(out) != 0 { return fmt.Errorf("hooksPath already set to %s", out) + } + + // Holds everything after the base 'git' in the command + gitCmd := []string{} + + if GitDirFlg != "" && WorkTreeFlg != "" { + gitCmd = []string{"--git-dir=" + GitDirFlg, "--work-tree=" + WorkTreeFlg, "config", "--local", "core.hooksPath", util.HkupDirName} } else { - if err := exec.Command("git", gitCmd...).Run(); err != nil { - return fmt.Errorf("failed to set hooksPath: %w", err) - } - return nil + gitCmd = []string{"config", "--local", "core.hooksPath", util.HkupDirName} } + + return exec.Command("git", gitCmd...).Run() } diff --git a/internal/logic/list.go b/internal/logic/list.go index ebb75c6..e633ef4 100644 --- a/internal/logic/list.go +++ b/internal/logic/list.go @@ -6,24 +6,23 @@ import ( "github.com/spf13/cobra" ) -// List displays a list of available Git hooks or supported languages based on the provided argument. -// It takes a single argument, which determines whether to list hooks or languages. +// List displays a list of available Git hooks or supported languages based on +// the provided argument. // -// Returns: -// - error: Returns an error if the argument is invalid; otherwise, it returns nil. +// Returns error if the argument is invalid. func List(cmd *cobra.Command, args []string) error { arg := args[0] - var output []string + out := []string{} - // NOTE: default case is handled by cobra framework + // NOTE: Default case is handled by cobra framework switch { case arg == "hook": - output = util.ConvertMapKeysToSlice(git.Hooks()) + out = util.ConvertMapKeysToSlice(git.Hooks()) case arg == "lang": - output = util.ConvertMapKeysToSlice(git.SupportedLangs()) + out = util.ConvertMapKeysToSlice(git.SupportedLangs()) } - for _, key := range output { + for _, key := range out { cmd.Printf(" %s\n", key) } diff --git a/internal/logic/main.go b/internal/logic/main.go index 4a11fb0..f56dbd5 100644 --- a/internal/logic/main.go +++ b/internal/logic/main.go @@ -1,14 +1,16 @@ /* -Package logic provides functionality for managing Git hooks, including commands to add, remove, and list hooks. -This package utilizes the cobra library for command-line interaction and is implemented in the respective commands of the [cmd] package. +Package logic provides functionality for managing Git hooks, including commands +to add, remove, and list hooks. +This package utilizes the cobra library for command-line interaction and is +implemented in the respective commands of the [github.com/iton0/hkup-cli/cmd] package. Commands: -- Init: Initialize HkUp. -- Add: Adds a new Git hook with the specified name and optional programming language. -- Remove: Removes an existing Git hook with the specified name. -- List: Lists all available Git hooks or supported Git hook languages. -- Doc: Opens browser for specified Git hook documentation. + - Init: Initializes HkUp. + - Add: Adds a new Git hook with the specified name and optional programming language. + - Remove: Removes an existing Git hook with the specified name. + - List: Lists all available Git hooks or supported Git hook languages. + - Doc: Opens browser for specified Git hook documentation. */ package logic -// Note: This file should be kept empty. +// NOTE: This file is for documentation purposes and should be kept empty. diff --git a/internal/logic/remove.go b/internal/logic/remove.go index a58d8dd..cb258f0 100644 --- a/internal/logic/remove.go +++ b/internal/logic/remove.go @@ -5,32 +5,38 @@ import ( "os" "path/filepath" + "github.com/iton0/hkup-cli/internal/git" "github.com/iton0/hkup-cli/internal/util" "github.com/spf13/cobra" ) -// Remove deletes a specified Git hook from the hkup folder. +// Remove deletes a specified Git hook from the .hkup directory. // It takes a single argument, which is the name of the hook to be removed. // -// Returns: -// - error: Returns an error if the hkup folder does not exist, if the specified hook is not found, -// or if there is an issue deleting the file; otherwise, it returns nil. +// Returns error if: +// - the .hkup directory does not exist +// - the specified hook is not found +// - issue deleting the file func Remove(cmd *cobra.Command, args []string) error { + // cannot remove if .hkup directory does not exist + if !util.DoesDirectoryExist(util.HkupDirName) { + return fmt.Errorf("failed running \"hkup remove\"\n%s directory does not exist", util.HkupDirName) + } + hook := args[0] - if !util.DoesDirectoryExist(FullPath) { - return fmt.Errorf("failed running \"hkup remove\"\n%s folder does not exist", FullPath) + // Validates that arg is a supported git hook + _, err := git.GetHook(hook) + if err != nil { + return err } - filePath := filepath.Join(FullPath, hook) + filePath := filepath.Join(util.HkupDirName, hook) + // Cannot remove if git hook does not exist in the .hkup directory if !util.DoesFileExist(filePath) { - return fmt.Errorf("not supported hook: %s", hook) - } - - if err := os.Remove(filePath); err != nil { - return fmt.Errorf("failed deleting file: %w", err) + return fmt.Errorf("hook does not exist in current working directory: %s", hook) } - return nil + return os.Remove(filePath) } diff --git a/internal/logic/template/copy.go b/internal/logic/template/copy.go new file mode 100644 index 0000000..5432d95 --- /dev/null +++ b/internal/logic/template/copy.go @@ -0,0 +1,107 @@ +package template + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/iton0/hkup-cli/internal/util" + "github.com/spf13/cobra" +) + +// Copy copies a git hook template to the .hkup directory. +// +// Returns error if: +// - HkUp config directory does not exist +// - .hkup directory does not exist in current working directory +// - arg is not valid template name +// - issue with copying template to .hkup directory +func Copy(cmd *cobra.Command, args []string) error { + configPath := util.GetConfigDirPath() + var templateName string + + // Only start the process of copying if config path and .hkup directory exist + switch { + case !util.DoesDirectoryExist(configPath): + return fmt.Errorf("%s directory does not exist.", configPath) + case !util.DoesDirectoryExist(util.HkupDirName): + return fmt.Errorf("%s directory does not exist in current working directory", util.HkupDirName) + default: + templateName = args[0] + } + + templatePath := util.GetTemplateDirPath() + file, err := doesTemplateExist(templatePath, templateName) + switch { + case err != nil: + return err + case file == "": + return fmt.Errorf("not a valid arg \"%s\" for \"hkup template copy\"", templateName) + default: + return performCopy(file) + } +} + +// doesTemplateExist checks if any file in the directory specified by templatePath +// starts with the given prefix (template name). +// +// Returns: +// - The full file path of the first file that matches the given prefix, or an empty string if no match is found. +// - An error if there is an issue reading the directory. +func doesTemplateExist(templatePath, name string) (string, error) { + files, err := os.ReadDir(templatePath) + if err != nil { + return "", err + } + + for _, file := range files { + if strings.HasPrefix(file.Name(), name) { + return filepath.Join(templatePath, file.Name()), nil + } + } + + return "", nil +} + +// performCopy copies the template file to the current working directory with +// appropiate git hook name. +// +// Returns error if: +// - template does not follow naming convetion +// - issues with copying or making executable +func performCopy(file string) error { + cleanPath, err := cleanPath(file) + if err != nil { + return err + } + + dstPath := filepath.Join(util.HkupDirName, cleanPath) + + err = util.CopyFile(file, dstPath) + if err != nil { + return err + } + + return util.MakeExecutable(dstPath) +} + +// cleanPath takes the template file path and returns the substring of the valid +// git hook file name. If the template path does not follow the convention of +// template path naming it will return an empty string and error. +// +// NOTE: This is automatically done by HkUp when using the CLI but user may +// want to manual add a git hook template to the HkUp config template directory. +// +// The convention should follow the custom name of the hook followed by a "#" +// and then the proper git hook name. +// +// Valid Naming Convention: [custom-name]#[hook-name] +// - ex). foo#post-commit +func cleanPath(path string) (string, error) { + if idx := strings.LastIndex(path, "#"); idx != -1 { + return path[idx+1:], nil + } + + return "", fmt.Errorf("template name must follow convention of \"[custom-name]#[hook-name]\"") +} diff --git a/internal/logic/template/create.go b/internal/logic/template/create.go new file mode 100644 index 0000000..47c95a0 --- /dev/null +++ b/internal/logic/template/create.go @@ -0,0 +1,324 @@ +package template + +import ( + "fmt" + "path/filepath" + + "github.com/iton0/hkup-cli/internal/git" + "github.com/iton0/hkup-cli/internal/util" + "github.com/spf13/cobra" +) + +var ( + // TemplateLangFlg is an optional flag indicating the language to use. + TemplateLangFlg string + + // TemplateNameFlg is an optional flag that holds the template name to be + // prepended to the template name. + TemplateNameFlg string + + // TemplateCwdFlg is an optional flag indicating to use the git hook from + // the current working directory. + TemplateCwdFlg bool + + // TemplateCopyFlg is an optional flag indicating to copy the created + // template to the current working directory. + TemplateCopyFlg bool + + // TemplateEditFlg is an optional flag indicating to edit the template. + TemplateEditFlg bool + + // template holds the information to create the new template. + // Info includes: + // - git hook name (hook) + // - language (lang) + // - custom template name (name) + // - if to use git hook in the current working directory (useCwd) + // - if to copy created template in the current working directory (copyHook) + // - if to edit the created template by opening editor (edit) + template = struct { + hook, lang, name string + useCwd, copyHook, edit bool + }{} +) + +// Create creates a git hook template from a specific git hook. +// +// Returns error if: +// - issue with creating HkUp config directory or template directory +// - issue with displaying prompt +// - issue with creating the template +func Create(cmd *cobra.Command, args []string) error { + configPath := util.GetConfigDirPath() + templatePath := util.GetTemplateDirPath() + + // Make the HkUp config directory if it does not exist + if !util.DoesDirectoryExist(configPath) { + cmd.Printf("Making HkUp config directory at %s...\n", configPath) + + err := util.CreateDirectory(configPath) + if err != nil { + return err + } + + // Also make the template subdirectory + err = util.CreateDirectory(templatePath) + if err != nil { + return err + } + } + + if len(args) == 1 { + if err := displayPrompt(templatePath, args[0]); err != nil { + return err + } + } else if err := displayPrompt(templatePath); err != nil { // no args given + return err + } + + // Either creating the template is successful and returns nil or unsuccessful + // and returns error + return createTemplate(templatePath) +} + +// createTemplate creates the template based on the given args and flags. +// Returns error if any operation fails. +func createTemplate(templatePath string) error { + fmt.Println() // Makes the output more distinct in regards to spacing + + // Full path to created template + createdTemplate := filepath.Join(templatePath, template.name+"#"+template.hook) + + // Copies git hook from current working directory to template directory + if template.useCwd { + srcPath := filepath.Join(util.HkupDirName, template.hook) + return util.CopyFile(srcPath, createdTemplate) // returns either nil or error + } + + file, err := util.CreateFile(createdTemplate) + if err != nil { + return err + } + defer file.Close() + + var fileContent string + if template.lang == "" { // Default to sh for the template language + fileContent = "#!/bin/sh\n\n\n\n\n" + } else { + fileContent = fmt.Sprintf("#!/usr/bin/env %s\n\n\n\n\n", template.lang) + } + + _, err = file.WriteString(fileContent) + if err != nil { + return err + } + + if template.edit { + if err := editTemplate(createdTemplate); err != nil { + return err + } + fmt.Println("Template successfully edited!") + } + + if template.copyHook { + dstPath := filepath.Join(util.HkupDirName, template.hook) + + err := util.CopyFile(createdTemplate, dstPath) + if err != nil { + return err + } + + err = util.MakeExecutable(dstPath) + if err != nil { + return err + } + + fmt.Println("Template copied to current working directory.") + } + + return nil +} + +// displayPrompt outputs appropiate prompts based on args and flags of command. +// Returns error if issue with displaying any of the sub prompts. +func displayPrompt(templatePath string, arg ...string) error { + fmt.Println() // Makes the output more distinct in regards to spacing + + // Takes user provided arg as hook name or asks for it + if len(arg) == 1 { + template.hook = arg[0] + fmt.Printf("Creating template with %s hook...\n\n", template.hook) + } else if err := displayHookPrompt(); err != nil { + return err + } + + // Takes name if name flag used or asks for it + if TemplateNameFlg != "" { + if out, err := doesTemplateExist(templatePath, TemplateNameFlg); err != nil { + return err + } else if out != "" { + return fmt.Errorf("template %s already exists\n", out) + } + template.name = TemplateNameFlg + } else if err := displayNamePrompt(templatePath); err != nil { + return err + } + + // Uses the current working directory's git hook if flag given or asks to do so + // NOTE: If cwd flag is used then utilizes language of that existing hook + if TemplateCwdFlg { + if !util.DoesFileExist(filepath.Join(util.HkupDirName, template.hook)) { + return fmt.Errorf("git hook %s does not exist in the current working directory", template.hook) + } + template.useCwd = true + } else { + err := displayCwdPrompt() + if err != nil { + return err + } + + // Takes language if lang flag used or asks for it + if TemplateLangFlg != "" { + if _, err = git.GetLang(TemplateLangFlg); err != nil { + return err + } + template.lang = TemplateLangFlg + } else if err = displayLangPrompt(); err != nil { + return err + } + + // Copies created template to cwd if copy flag used or asks to do so + if TemplateCopyFlg { + template.copyHook = true + } else if !template.useCwd { // Does not copy what is already in cwd + if err = displayCopyPrompt(); err != nil { + return err + } + } + + // Created template will be opened in editor or asks to do so + if TemplateEditFlg { + template.edit = true + } else if err = displayEditPrompt(); err != nil { + return err + } + } + + return nil +} + +// displayHookPrompt asks for valid git hook name to use for template. +// Returns error is issue with reading response. +func displayHookPrompt() error { + in, err := util.UserInputPrompt("Git hook name:") + if err != nil { + return err + } + + // Recursively calls this function until supplied with supported git hook + if _, err = git.GetHook(in); err != nil { + fmt.Println("Not a supported Git hook. Please try again") + return displayHookPrompt() + } + + template.hook = in + return nil +} + +// displayCwdPrompt asks whether to use current working directory's git hook as +// template. +// Returns error is issue with reading response. +func displayCwdPrompt() error { + // Does not display if the git hook type does not exist in the cwd + if !util.DoesFileExist(filepath.Join(util.HkupDirName, template.hook)) { + return nil + } + + yes, err := util.YesNoPrompt("Use from current working directory?") + if err != nil { + return err + } + + // useCwd field is false by default so only need to check if "yes" + if yes { + template.useCwd = true + } + return nil +} + +// displayLangPrompt asks what language to use for template. +// Returns error if is issue with reading reponse. +func displayLangPrompt() error { + // Does not display if we are using the existing git hook in cwd + if template.useCwd { + return nil + } + + switch in, err := util.UserInputPrompt("Language (default sh):"); { + case err != nil: + return err + case in == "": // using the default sh as the language for the hook + return nil + default: + // Recursively calls this function until supplied with supported language + if _, err = git.GetLang(in); err != nil { + fmt.Println("Not a supported language. Please try again") + return displayLangPrompt() + } + + template.lang = in + return nil + } +} + +// displayNamePrompt asks for the name of the template. +// Returns error if: +// - issue with reading response +// - issue with checking config template directory +func displayNamePrompt(templatePath string) error { + in, err := util.UserInputPrompt("Template Name:") + if err != nil { + return err + } + + if out, err := doesTemplateExist(templatePath, in); err != nil { + return err + } else if out != "" { // Keeps asking until given a unique template name + fmt.Println("Template name already exists. Please try again") + return displayNamePrompt(templatePath) + } + + template.name = in + return nil +} + +// displayCopyPrompt asks whether to copy the template to the current working +// directory. +// Returns an error if issue with reading reponse. +func displayCopyPrompt() error { + yes, err := util.YesNoPrompt("Copy to current working directory?") + if err != nil { + return err + } + + // copyHook field is false by default so only need to check if "yes" + if yes { + template.copyHook = true + } + return nil +} + +// displayEditPrompt asks whether to edit the created template. +// Returns an error if issue with reading reponse. +func displayEditPrompt() error { + yes, err := util.YesNoPrompt("Edit template?") + if err != nil { + return err + } + + // edit field is false by default so only need to check if "yes" + if yes { + template.edit = true + } + return nil +} diff --git a/internal/logic/template/edit.go b/internal/logic/template/edit.go new file mode 100644 index 0000000..3722079 --- /dev/null +++ b/internal/logic/template/edit.go @@ -0,0 +1,58 @@ +package template + +import ( + "fmt" + "os" + "os/exec" + + "github.com/iton0/hkup-cli/internal/util" + "github.com/spf13/cobra" +) + +// Edit opens specified template in the default editor for HkUp. +// +// Returns error if: +// - template is not valid +// - editor is not found +func Edit(cmd *cobra.Command, args []string) error { + templatePath := util.GetTemplateDirPath() + + // output (without error) will either give path to template or empty string + out, err := doesTemplateExist(templatePath, args[0]) + switch { + case err != nil: + return err + case out == "": + return fmt.Errorf("%s template does not exist", args[0]) + default: + return editTemplate(out) + } +} + +// editTemplate opens the template file with the default editor for HkUp. +// Returns error if issue with opening editor. +func editTemplate(path string) error { + editor, err := util.GetEditor() + if err != nil { + return err + } + + // Create the command to open the editor with the template file + cmd := exec.Command(editor, path) + + // This allows the editor to be opened in the same terminal + // Source: https://stackoverflow.com/questions/12088138/trying-to-launch-an-external-editor-from-within-a-go-program#12089980 + // NOTE: This only applies to terminal-based editors such as vim, nvim, etc. + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Starts the editor + err = cmd.Start() + if err != nil { + return err + } + + // Waits for the user to finish editing + return cmd.Wait() // Either success and returns nil or returns error if issue +} diff --git a/internal/logic/template/main.go b/internal/logic/template/main.go new file mode 100644 index 0000000..9522264 --- /dev/null +++ b/internal/logic/template/main.go @@ -0,0 +1,15 @@ +/* +Package template provides functionality for creating and copying git hook templates +This package utilizes the cobra library for command-line interaction and is +implemented in the respective commands of the +[github.com/iton0/hkup-cli/cmd/template] package. + +Commands: + - Copy: Copies a template from template name. + - Create: Creates a git hook template. + - Remove: Removes a template from template name. + - Edit: Edits a template from template name. +*/ +package template + +// NOTE: This file is for documentation purposes and should be kept empty. diff --git a/internal/logic/template/remove.go b/internal/logic/template/remove.go new file mode 100644 index 0000000..9134078 --- /dev/null +++ b/internal/logic/template/remove.go @@ -0,0 +1,34 @@ +package template + +import ( + "fmt" + "os" + + "github.com/iton0/hkup-cli/internal/util" + "github.com/spf13/cobra" +) + +// Remove removes the template file from the HkUp config template directory. +// Returns error if: +// - template does not follow naming convetion +// - issues with removing file +func Remove(cmd *cobra.Command, args []string) error { + templatePath := util.GetTemplateDirPath() + + // Cannot remove template if HkUp template config path does not exist + if !util.DoesDirectoryExist(templatePath) { + return fmt.Errorf("%s directory does not exist.", templatePath) + } + + templateName := args[0] + + // Checks for template existence in HkUp template config directory + switch file, err := doesTemplateExist(templatePath, templateName); { + case err != nil: + return err + case file == "": // Specified template does not exist + return fmt.Errorf("not valid arg \"%s\" for \"hkup template remove\"", templateName) + default: // Template exists and will try to remove + return os.Remove(file) // Either success and returns nil or returns error + } +} diff --git a/internal/util/main.go b/internal/util/main.go index 04595b6..6725adb 100644 --- a/internal/util/main.go +++ b/internal/util/main.go @@ -1,62 +1,133 @@ /* -Package util provides utility functions for file and directory operations, including creating folders and files, checking existence of files and directories, and converting map keys to slices. +Package util provides utility functions for tasks and operations such as: + - terminal prompt creation + - file/directory operations + - retrieval of related system information + - getting/setting of HkUp configuration settings + - mutation of related data structures -This package is designed to simplify common file and directory management tasks for hkup related commands in the [internal/logic] package. +Additionally, this package holds all constant values used throughout the +application such as: + - Git hook documentation site + - HkUp related directory names + +This package is designed to abstract the above values, tasks, and operations to +be reusable throughout the HkUp application. */ package util import ( + "bufio" "fmt" + "io" "os" + "os/exec" + "path/filepath" + "strings" ) -// CreateFolder creates a new directory at the specified path. Returns an error if the operation fails. -func CreateFolder(dirPath string) error { - if err := os.Mkdir(dirPath, os.ModePerm); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dirPath, err) - } - return nil +const ( + // HkupDirName defines HkUp directory name within current working directory. + HkupDirName = ".hkup" +) + +// CreateDirectory makes a new directory at the specified path. +// Returns an error if the operation fails. +func CreateDirectory(path string) error { + return os.Mkdir(path, os.ModePerm) } -// CreateFile creates a new file in the specified directory with the given name. Returns an error if the operation fails. -func CreateFile(dirPath string, name string) error { - filePath := dirPath + name - file, err := os.Create(filePath) +// CreateFile makes a new file in the specified file path name. +// Returns pointer to the new file and an error if the operation fails. +// NOTE: CreateFile does not close the file. +func CreateFile(path string) (*os.File, error) { + file, err := os.Create(path) if err != nil { - return fmt.Errorf("failed to create file %s: %w", filePath, err) + return nil, err } - defer func(file *os.File) { - err := file.Close() - if err != nil { - panic(err) - } - }(file) - - return nil + return file, nil } // DoesDirectoryExist reports if a directory exists at the specified path. -func DoesDirectoryExist(dirPath string) bool { - info, err := os.Stat(dirPath) - if os.IsNotExist(err) { +func DoesDirectoryExist(path string) bool { + if info, err := os.Stat(path); os.IsNotExist(err) { return false + } else { + return info.IsDir() } - return info.IsDir() } // DoesFileExist reports if a file exists at the specified path. -func DoesFileExist(filePath string) bool { - info, err := os.Stat(filePath) - if os.IsNotExist(err) { +func DoesFileExist(path string) bool { + if info, err := os.Stat(path); os.IsNotExist(err) { return false + } else { + return !info.IsDir() + } +} + +// GetConfigDirPath returns the available HkUp config directory path. +func GetConfigDirPath() (configPath string) { + if xdgVar, exist := os.LookupEnv("XDG_CONFIG_HOME"); exist && xdgVar != "" { + configPath = filepath.Join(xdgVar, "hkup") + } else { + configPath = filepath.Join(os.Getenv("HOME"), ".config", "hkup") + } + + return configPath +} + +// GetConfigFilePath returns the HkUp file path that holds configuration settings. +func GetConfigFilePath() string { + return filepath.Join(GetConfigDirPath(), ".hkupconfig") +} + +// GetTemplateDirPath returns the HkUp config template directory path. +func GetTemplateDirPath() string { + return filepath.Join(GetConfigDirPath(), "templates") +} + +// CopyFile copies a file (without overwriting) from src file path to dest file path. +// Returns error if: +// - destination path exists +// - issue with any steps of copying +func CopyFile(src, dst string) error { + if _, err := os.Stat(dst); err == nil { + return fmt.Errorf("destination file already exists: %s", dst) + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to check if destination file exists: %w", err) + } + + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.Create(dst) + if err != nil { + return err + } + defer dstFile.Close() + + if _, err = io.Copy(dstFile, srcFile); err != nil { + return err } - return !info.IsDir() + + return nil } -// ConvertMapKeysToSlice converts the keys of a map into a returned slice of strings. +// MakeExecutable makes the filePath executable. +// Returns error if issue with making executable. +func MakeExecutable(filePath string) error { + return os.Chmod(filePath, 0755) +} + +// ConvertMapKeysToSlice transforms the map string keys into a returned slice +// of strings. func ConvertMapKeysToSlice[T comparable](m map[string]T) []string { - var keys []string + keys := []string{} for key := range m { keys = append(keys, key) @@ -64,3 +135,168 @@ func ConvertMapKeysToSlice[T comparable](m map[string]T) []string { return keys } + +// YesNoPrompt displays the specified prompt message to the user and asks for a +// yes/no response. +// Returns boolean and error if issue occurred during the input process. +func YesNoPrompt(prompt string) (bool, error) { + fmt.Print(prompt + "(Y/n): ") + + scanner := bufio.NewScanner(os.Stdin) + + if !scanner.Scan() { + return false, fmt.Errorf("failed to read response") + } + + response := strings.TrimSpace(scanner.Text()) + + // Pressing Enter key is equivalent to yes + if response == "" || response == "y" || response == "Y" { + return true, nil + } + + return false, nil +} + +// UserInputPrompt prompts the user with the specified message and waits for +// the user to enter a response. +// Returns reponse and error if issue occurred during the input process. +func UserInputPrompt(prompt string) (string, error) { + fmt.Print(prompt + " ") + + scanner := bufio.NewScanner(os.Stdin) + + if !scanner.Scan() { + return "", fmt.Errorf("failed to read response") + } + + return strings.TrimSpace(scanner.Text()), nil +} + +// GetEditor makes best effort to find default editor for HkUp. +// Returns editor name if found and error if issue with searching for editor. +func GetEditor() (string, error) { + // Check the HkUp config file + editor, err := GetTOMLValue(GetConfigFilePath(), "editor") + if err != nil { + return "", err + } else if editor != "" { + return editor, nil + } + + // Check in global gitconfig file + if out, err := exec.Command("git", "config", "--global", "core.editor").CombinedOutput(); err != nil { + return "", err + } else if len(out) != 0 { + // The out has a newline character at the end so take elements up until the + // "\" of the "\n" + return string(out[0:(len(out) - 1)]), nil // Converts byte slice into string + } + + // Check for EDITOR var + if editor, exist := os.LookupEnv("EDITOR"); exist && editor != "" { + return editor, nil + } + + return "", fmt.Errorf("failed to find an editor") +} + +// GetTOMLValue gets the value of a specific key from a flat TOML file. +// Returns value and error if issue with opening or reading file. +func GetTOMLValue(filePath, key string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", nil + } + defer file.Close() + + // Scanner to read the file line by line + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + line = strings.TrimSpace(line) + + // Skip comments or empty lines + if len(line) == 0 || line[0] == '#' || line[0] == ';' { + continue + } + + // Split the line into key and value + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue // Skip malformed lines + } + + keyInFile := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Remove quotes around string values (if any) + if len(value) > 1 && value[0] == '"' && value[len(value)-1] == '"' { + value = value[1 : len(value)-1] + } + + // If the current line matches the key you're looking for, return the value + if keyInFile == key { + return value, nil + } + } + + // Handle the case where the key was not found + if err := scanner.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("%s is not a valid key", key) // Returns empty string if key not found +} + +// SetTOMLValue modifies the value of a key in a flat TOML file. +// Returns error if key not found or issue with reading or wriiting to file. +func SetTOMLValue(filePath, key, newValue string) error { + // Open the TOML file + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + updatedLines := []string{} + var keyFound bool // defaults to false + + // Scanner to read the file line by line + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines or comments + if len(line) == 0 || line[0] == '#' || line[0] == ';' { + updatedLines = append(updatedLines, line) + continue + } + + // Split the line into key and value + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + keyInFile := strings.TrimSpace(parts[0]) + + // If the key matches, update the value + if keyInFile == key { + line = fmt.Sprintf("%s = %s", keyInFile, newValue) + keyFound = true + } + } + + updatedLines = append(updatedLines, line) + } + + // If the key was not found, return an error + if !keyFound { + return fmt.Errorf("key '%s' not found", key) + } + + // Write the updated content back to the file + err = os.WriteFile(filePath, []byte(strings.Join(updatedLines, "\n")), 0644) + if err != nil { + return err + } + + return nil +} diff --git a/scripts/build b/scripts/build deleted file mode 100755 index 4979386..0000000 --- a/scripts/build +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -mkdir -p ./bin - -GOOS=linux GOARCH=amd64 go build -o ./bin/hkup-linux -ldflags="-s -w" . -GOOS=darwin GOARCH=amd64 go build -o ./bin/hkup-darwin -ldflags="-s -w" . -# GOOS=windows GOARCH=amd64 go build -o ./bin/hkup.exe -ldflags="-s -w" . diff --git a/scripts/install b/scripts/install index 80b6486..95f4f6e 100755 --- a/scripts/install +++ b/scripts/install @@ -28,10 +28,31 @@ esac LATEST_RELEASE=$(curl -s "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') echo "Latest version: $LATEST_RELEASE" +# Get the current installed version (if any) +if [ -f "$INSTALL_PATH" ]; then + INSTALLED_VERSION=$($INSTALL_PATH --version 2>/dev/null) + echo "Currently installed version: $INSTALLED_VERSION" +else + INSTALLED_VERSION="None" + echo "No version installed." +fi + +# Check if HkUp is installed and if local version matches latest release version +if [ "$INSTALLED_VERSION" != "None" ] && [ "$(echo "$INSTALLED_VERSION" | cut -d' ' -f3)" = "$(echo "$LATEST_RELEASE" | cut -c2-)" ]; then + echo "You already have the latest version installed." + exit 0 +fi + +echo "" + # Download the binary for the latest release to a temporary location TEMP_PATH=$(mktemp) echo "Downloading $BINARY_NAME version $LATEST_RELEASE..." curl -L "https://github.com/$REPO/releases/download/$LATEST_RELEASE/$BINARY_NAME" -o "$TEMP_PATH" +if [ $? -ne 0 ]; then + echo "Failed to download the binary." + exit 1 +fi # Move the downloaded binary to the installation path sudo mv "$TEMP_PATH" "$INSTALL_PATH" @@ -41,5 +62,30 @@ if [ "$(uname)" != "CYGWIN" ] && [ "$(uname)" != "MINGW" ]; then sudo chmod +x "$INSTALL_PATH" # Use sudo to change permissions fi -echo "hkup installed successfully!" +# Make the HkUp configuration directory and its contents +# Check if XDG_CONFIG_HOME is set and not empty +if [ -z "$XDG_CONFIG_HOME" ]; then + # If XDG_CONFIG_HOME is not set or empty, use $HOME/.config + CONFIG_DIR="$HOME/.config/hkup/templates" +else + # Otherwise, use $XDG_CONFIG_HOME + CONFIG_DIR="$XDG_CONFIG_HOME/hkup/templates" +fi + +mkdir -p "$CONFIG_DIR" + +# TODO: uncomment when finalized config settings +# # Check if the file exists +# FILE="$CONFIG_DIR/.hkupconfig" +# if [ ! -f "$FILE" ]; then +# # If the file does not exist, create it and add text +# echo "editor = \"\"" > "$FILE" +# fi +echo "" + +if [ "$INSTALLED_VERSION" != "None" ]; then + echo "hkup updated successfully!" +else + echo "hkup installed successfully!" +fi