vagrant-git-hooks 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9bcb94570bf05bac8dccbbf2014d27040d72ce2c5c9f6488c4792780fe52f234
4
+ data.tar.gz: fe129e89c75782fec7817804b22cdf15c984281874c8ec27fca27d39f6c8378a
5
+ SHA512:
6
+ metadata.gz: cdc518ee7647648c5f13ac7e902dedeffd843af6214d71d2674644b12ef0b9624a924d2b00747b2ec9c9a90a0f3ea38ba5bbcef1ad2c62e03024df997cc1ebd2
7
+ data.tar.gz: 2ad7b7a2f3f43053530e1461ea9c87b33179a051a1ba9ffb6d50687d7d25f1d4993abeaea3e578f8e231cb89637104cf87176e075e05194271a6de8b4a1f885a
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/julienpoirou/vagrant-git-hooks/compare/v0.1.0...v0.2.0) (2026-07-05)
4
+
5
+
6
+ ### Fonctionnalités ✨
7
+
8
+ * Add git hooks plugin implementation and RubyDoc publishing ([59218e3](https://github.com/julienpoirou/vagrant-git-hooks/commit/59218e36a20b0a439feaf2347bb071dfeb82585f))
9
+
10
+
11
+ ### Corrections 🐛
12
+
13
+ * Wire the workflow_dispatch tag input and make Publish release-safe ([f3b1a84](https://github.com/julienpoirou/vagrant-git-hooks/commit/f3b1a843c1abccef00814e6349e76d8f306455c3))
14
+
15
+ ## Changelog
16
+
17
+ All notable changes to this project are documented in this file. The format is
18
+ managed automatically by [release-please](https://github.com/googleapis/release-please)
19
+ from [Conventional Commits](https://www.conventionalcommits.org/).
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julien Poirou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # vagrant-git-hooks
2
+
3
+ 🇬🇧 English · [🇫🇷 Français](./README.fr.md)
4
+
5
+ Manage your project's **git hooks from the Vagrantfile** — a [husky](https://github.com/typicode/husky)
6
+ replacement that needs **no Node/npm**, only Vagrant. Stop adding a `package.json`
7
+ and `node_modules` to a Go/Ruby/Python repo just to lint on commit.
8
+
9
+ ## Why
10
+
11
+ Husky is great, but it *is* a Node package. `vagrant-git-hooks` moves hook
12
+ management into the tool you already use to describe your environment — Vagrant —
13
+ so the only dependency is Vagrant itself. Hooks are plain POSIX shell and run on
14
+ the host, exactly like husky's.
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ vagrant plugin install vagrant-git-hooks
20
+ ```
21
+
22
+ ## Configure
23
+
24
+ ```ruby
25
+ Vagrant.configure("2") do |config|
26
+ config.git_hooks.hooks = {
27
+ "pre-commit" => "rubocop --parallel",
28
+ "commit-msg" => "commitlint --edit $1",
29
+ "pre-push" => ["bundle exec rake spec", "echo pushed"]
30
+ }
31
+
32
+ # Optional: adopt one-file-per-hook scripts from a versioned directory.
33
+ config.git_hooks.hooks_source = "scripts/githooks"
34
+
35
+ # Defaults:
36
+ # config.git_hooks.install_on_up = true # install on `vagrant up`/provision
37
+ # config.git_hooks.remove_on_destroy = false # keep hooks on `vagrant destroy`
38
+ # config.git_hooks.fail_on_error = true # inject `set -e` (a failing hook blocks the commit)
39
+ end
40
+ ```
41
+
42
+ A value can be a `String` (single line) or an `Array<String>` (several lines).
43
+ Your `$1` / `"$@"` are passed through — git feeds the hook its own arguments.
44
+
45
+ On `vagrant up` the plugin writes each hook into the repository hooks directory
46
+ (resolved via `git rev-parse --git-path hooks`, so `core.hooksPath` and worktrees
47
+ are honored) with a managed header:
48
+
49
+ ```sh
50
+ #!/usr/bin/env sh
51
+ # >>> vagrant-git-hooks (managed) >>>
52
+ # Managed by Vagrant - do not edit manually (hook: pre-commit)
53
+ set -e
54
+ rubocop --parallel
55
+ # <<< vagrant-git-hooks (managed) <<<
56
+ ```
57
+
58
+ If a non-managed hook already exists, it is backed up to `<hook>.bak` before being
59
+ replaced, and restored on uninstall.
60
+
61
+ ## CLI
62
+
63
+ | Command | Description |
64
+ |---|---|
65
+ | `vagrant hooks install` | Install configured hooks (`--dry-run`, `--json`) |
66
+ | `vagrant hooks uninstall` | Remove managed hooks, restore backups (`--yes`) |
67
+ | `vagrant hooks list` | Show each hook's state: `installed` / `missing` / `modified` / `foreign` |
68
+ | `vagrant hooks run <hook> [args]` | Run a configured hook on the host (test without committing) |
69
+ | `vagrant hooks version` | Print the plugin version |
70
+ | `vagrant hooks help [topic]` | Help |
71
+
72
+ Global flags: `--json`, `--no-emoji`, `--lang en|fr`.
73
+
74
+ ## husky → vagrant-git-hooks
75
+
76
+ | husky | vagrant-git-hooks |
77
+ |---|---|
78
+ | `npx husky init` + `.husky/<hook>` files | `config.git_hooks.hooks = { … }` in the Vagrantfile |
79
+ | installed on `npm install` (Node) | installed on `vagrant up` (Vagrant) |
80
+ | `package.json` + `node_modules` | nothing extra — just Vagrant |
81
+
82
+ ## License
83
+
84
+ MIT — see [LICENSE.md](./LICENSE.md).
@@ -0,0 +1 @@
1
+ 0.2.0
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+ require "tempfile"
6
+
7
+ require_relative "helpers"
8
+ require_relative "config"
9
+ require_relative "service"
10
+ require_relative "hook_builder"
11
+ require_relative "util/git"
12
+ require_relative "util/verbose"
13
+ require_relative "version"
14
+
15
+ module VagrantGitHooks
16
+ BASE_CMD = if defined?(Vagrant) && Vagrant.respond_to?(:plugin)
17
+ Vagrant.plugin("2", :command)
18
+ else
19
+ Class.new do
20
+ def initialize(argv = [], env = {})
21
+ @argv = argv || []
22
+ @env = env || {}
23
+ end
24
+
25
+ def parse_options(parser)
26
+ parser.permute!(@argv)
27
+ @argv
28
+ rescue OptionParser::InvalidOption
29
+ nil
30
+ end
31
+ end
32
+ end
33
+
34
+ class Command < BASE_CMD
35
+ def execute
36
+ UiHelpers.setup_i18n!
37
+ @opts = { json: false, no_emoji: false, lang: nil, yes: false, dry_run: false }
38
+
39
+ argv = parse_options(build_parser)
40
+ return 0 unless argv
41
+
42
+ apply_locale_and_flags
43
+ dispatch(argv.shift, argv)
44
+ end
45
+
46
+ private
47
+
48
+ def dispatch(sub, argv)
49
+ case sub
50
+ when "version" then cmd_version
51
+ when nil, "", "help" then UiHelpers.print_topic_help(argv.shift)
52
+ 0
53
+ when "install" then with_project { |root, cfg| do_install(root, cfg) }
54
+ when "uninstall", "remove" then with_project { |root, cfg| do_uninstall(root, cfg) }
55
+ when "list", "status" then with_project { |root, cfg| do_list(root, cfg) }
56
+ when "run" then with_project { |root, cfg| do_run(root, cfg, argv) }
57
+ else
58
+ err("#{UiHelpers.e(:error, no_emoji: @opts[:no_emoji])} #{UiHelpers.t("errors.unknown_command")}")
59
+ 1
60
+ end
61
+ end
62
+
63
+ def build_parser
64
+ OptionParser.new do |o|
65
+ o.banner = UiHelpers.t("usage.banner")
66
+ o.on("--json", "Machine-readable JSON output") { @opts[:json] = true }
67
+ o.on("--no-emoji", "Disable emoji in output") { @opts[:no_emoji] = true }
68
+ o.on("--lang LANG", "Force language (en|fr)") { |v| @opts[:lang] = v }
69
+ o.on("--dry-run", "Show what would change") { @opts[:dry_run] = true }
70
+ o.on("-y", "--yes", "Auto-confirm") { @opts[:yes] = true }
71
+ end
72
+ end
73
+
74
+ def apply_locale_and_flags
75
+ ENV["VGH_NO_EMOJI"] = "1" if @opts[:no_emoji]
76
+ return unless @opts[:lang]
77
+
78
+ begin
79
+ UiHelpers.set_locale!(@opts[:lang])
80
+ rescue UiHelpers::UnsupportedLocaleError
81
+ UiHelpers.set_locale!("en")
82
+ end
83
+ end
84
+
85
+ def with_project
86
+ root = @env.respond_to?(:root_path) ? @env.root_path : nil
87
+ unless root
88
+ err("#{UiHelpers.e(:error, no_emoji: @opts[:no_emoji])} #{UiHelpers.t("errors.no_machine")}")
89
+ return 1
90
+ end
91
+
92
+ cfg = @env.vagrantfile.config.git_hooks
93
+ resolve_cli_locale!(cfg)
94
+ yield root.to_s, cfg
95
+ end
96
+
97
+ def resolve_cli_locale!(cfg)
98
+ return if @opts[:lang]
99
+ return unless ENV["VGH_LANG"].to_s.strip.empty?
100
+ return unless cfg.respond_to?(:locale) && cfg.locale
101
+
102
+ begin
103
+ UiHelpers.set_locale!(cfg.locale)
104
+ rescue UiHelpers::UnsupportedLocaleError
105
+ UiHelpers.set_locale!("en")
106
+ end
107
+ end
108
+
109
+ def do_install(root, cfg)
110
+ return fail_not_repo("install") unless git_repo?(root)
111
+
112
+ res = Service.install!(root: root, cfg: cfg, ui: ui, dry_run: @opts[:dry_run])
113
+ return emit("install", "success", res) if @opts[:json]
114
+
115
+ total = res[:installed].size + res[:updated].size
116
+ if total.zero?
117
+ say("#{mark(:info)} #{UiHelpers.t("messages.no_hooks")}")
118
+ else
119
+ say("#{mark(:success)} #{UiHelpers.t("messages.installed", count: total)}")
120
+ unless res[:backed_up].empty?
121
+ say("#{mark(:info)} #{UiHelpers.t("messages.backed_up", list: res[:backed_up].join(", "))}")
122
+ end
123
+ end
124
+ 0
125
+ end
126
+
127
+ def do_uninstall(root, _cfg)
128
+ return fail_not_repo("uninstall") unless git_repo?(root)
129
+
130
+ unless @opts[:yes]
131
+ return err_code("uninstall", UiHelpers.t("errors.confirmation_required")) if @opts[:json]
132
+
133
+ unless confirm?("#{mark(:question)} #{UiHelpers.t("prompts.uninstall")}")
134
+ return err_code("uninstall", UiHelpers.t("errors.cancelled"))
135
+ end
136
+ end
137
+
138
+ res = Service.uninstall!(root: root, ui: ui, dry_run: @opts[:dry_run])
139
+ return emit("uninstall", "success", res) if @opts[:json]
140
+
141
+ say("#{mark(:broom)} #{UiHelpers.t("messages.removed", count: res[:removed].size)}")
142
+ say("#{mark(:info)} #{UiHelpers.t("messages.restored", count: res[:restored].size)}") unless res[:restored].empty?
143
+ 0
144
+ end
145
+
146
+ def do_list(root, cfg)
147
+ rows = Service.status(root: root, cfg: cfg)
148
+ return emit("list", "success", { hooks: rows }) if @opts[:json]
149
+
150
+ if rows.empty?
151
+ say("#{mark(:info)} #{UiHelpers.t("messages.no_hooks")}")
152
+ return 0
153
+ end
154
+
155
+ say("#{mark(:info)} #{UiHelpers.t("messages.list_header")}")
156
+ rows.each { |r| say(format(" • %-22s %s", r[:name], r[:state])) }
157
+ 0
158
+ end
159
+
160
+ def do_run(_root, cfg, argv)
161
+ name = argv.shift
162
+ return err_code("run", UiHelpers.t("usage.run")) if name.to_s.strip.empty?
163
+
164
+ cmd = (cfg.hooks || {})[name]
165
+ return err_code("run", UiHelpers.t("errors.hook_not_configured", name: name)) if cmd.nil?
166
+
167
+ script = HookBuilder.script_for(name: name, commands: cmd, fail_on_error: cfg.fail_on_error)
168
+ Tempfile.create(["vgh-", ""]) do |f|
169
+ f.write(script)
170
+ f.flush
171
+ File.chmod(0o755, f.path) unless Gem.win_platform?
172
+ Util::Verbose.log("sh", f.path, *argv)
173
+ system("sh", f.path, *argv) ? 0 : 1
174
+ end
175
+ end
176
+
177
+ def cmd_version
178
+ return emit("version", "success", { version: VERSION }) if @opts[:json]
179
+
180
+ say("#{mark(:version)} #{UiHelpers.t("log.version_line", version: VERSION)}")
181
+ 0
182
+ end
183
+
184
+ def git_repo?(root)
185
+ Util::Git.available? && Util::Git.repo?(root)
186
+ end
187
+
188
+ def fail_not_repo(action)
189
+ err_code(action, UiHelpers.t("errors.not_a_repo"))
190
+ end
191
+
192
+ def err_code(action, message)
193
+ return emit(action, "error", { error: message }) if @opts[:json]
194
+
195
+ err("#{mark(:error)} #{message}")
196
+ 1
197
+ end
198
+
199
+ def emit(action, status, data)
200
+ puts JSON.generate({ action: action, status: status }.merge(data))
201
+ status == "success" ? 0 : 1
202
+ end
203
+
204
+ def mark(key)
205
+ UiHelpers.e(key, no_emoji: @opts[:no_emoji])
206
+ end
207
+
208
+ def confirm?(prompt)
209
+ return true if @opts[:yes]
210
+
211
+ print "#{prompt} "
212
+ %w[y yes o oui].include?($stdin.gets.to_s.strip.downcase)
213
+ end
214
+
215
+ def ui
216
+ @env.respond_to?(:ui) ? @env.ui : nil
217
+ end
218
+
219
+ def say(msg)
220
+ UiHelpers.say(ui, msg)
221
+ end
222
+
223
+ def err(msg)
224
+ UiHelpers.error(ui, msg)
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VagrantGitHooks
4
+ # Vagrant configuration for managed Git hooks.
5
+ #
6
+ # @!attribute hooks
7
+ # @return [Hash{String=>String,Array<String>}] Hook commands keyed by hook name.
8
+ # @!attribute hooks_source
9
+ # @return [String, nil] Directory containing hook scripts to adopt.
10
+ # @!attribute install_on_up
11
+ # @return [Boolean] Whether hooks are installed during `vagrant up`.
12
+ # @!attribute remove_on_destroy
13
+ # @return [Boolean] Whether managed hooks are removed during `vagrant destroy`.
14
+ # @!attribute fail_on_error
15
+ # @return [Boolean] Whether generated hooks should stop on the first failing command.
16
+ class Config < Vagrant.plugin("2", :config)
17
+ KNOWN_HOOKS = %w[
18
+ applypatch-msg pre-applypatch post-applypatch
19
+ pre-commit pre-merge-commit prepare-commit-msg commit-msg post-commit
20
+ pre-rebase post-checkout post-merge pre-push pre-auto-gc
21
+ post-rewrite post-index-change reference-transaction fsmonitor-watchman
22
+ p4-changelist p4-prepare-changelist p4-post-changelist p4-pre-submit
23
+ ].freeze
24
+
25
+ attr_accessor :hooks, :hooks_source, :install_on_up, :remove_on_destroy,
26
+ :fail_on_error, :locale, :verbose
27
+
28
+ def initialize
29
+ @hooks = UNSET_VALUE
30
+ @hooks_source = UNSET_VALUE
31
+ @install_on_up = UNSET_VALUE
32
+ @remove_on_destroy = UNSET_VALUE
33
+ @fail_on_error = UNSET_VALUE
34
+ @locale = UNSET_VALUE
35
+ @verbose = UNSET_VALUE
36
+ end
37
+
38
+ def finalize!
39
+ @hooks = {} if @hooks == UNSET_VALUE
40
+ @hooks_source = nil if @hooks_source == UNSET_VALUE
41
+ @install_on_up = true if @install_on_up == UNSET_VALUE
42
+ @remove_on_destroy = false if @remove_on_destroy == UNSET_VALUE
43
+ @fail_on_error = true if @fail_on_error == UNSET_VALUE
44
+ @locale = "en" if @locale == UNSET_VALUE
45
+ @verbose = false if @verbose == UNSET_VALUE
46
+ end
47
+
48
+ def validate(_machine)
49
+ errors = []
50
+
51
+ validate_hooks(errors)
52
+
53
+ errors << "hooks_source must be a String path" if @hooks_source && !@hooks_source.is_a?(String)
54
+
55
+ errors << "install_on_up must be boolean" unless boolean?(@install_on_up)
56
+ errors << "remove_on_destroy must be boolean" unless boolean?(@remove_on_destroy)
57
+ errors << "fail_on_error must be boolean" unless boolean?(@fail_on_error)
58
+
59
+ unless @locale.is_a?(String) && %w[en fr].include?(@locale.to_s[0, 2].downcase)
60
+ errors << "locale must be 'en' or 'fr'"
61
+ end
62
+
63
+ { "vagrant-git-hooks" => errors }
64
+ end
65
+
66
+ private
67
+
68
+ def validate_hooks(errors)
69
+ unless @hooks.is_a?(Hash)
70
+ errors << 'hooks must be a Hash of { "hook-name" => "command" | [commands] }'
71
+ return
72
+ end
73
+
74
+ @hooks.each do |name, cmd|
75
+ errors << "unknown git hook: #{name}" unless KNOWN_HOOKS.include?(name.to_s)
76
+
77
+ unless cmd.is_a?(String) || (cmd.is_a?(Array) && cmd.all?(String))
78
+ errors << "hook '#{name}' command must be a String or Array of Strings"
79
+ next
80
+ end
81
+
82
+ blank = cmd.is_a?(String) ? cmd.strip.empty? : Array(cmd).all? { |c| c.to_s.strip.empty? }
83
+ errors << "hook '#{name}' command is empty" if blank
84
+ end
85
+ end
86
+
87
+ def boolean?(val)
88
+ [true, false].include?(val)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "i18n"
5
+
6
+ module VagrantGitHooks
7
+ module UiHelpers
8
+ class MissingTranslationError < StandardError; end
9
+ class UnsupportedLocaleError < StandardError; end
10
+
11
+ SUPPORTED = %i[en fr].freeze
12
+ OUR_NAMESPACES = %w[messages. errors. usage. help. log. emoji.].freeze
13
+
14
+ EMOJI = {
15
+ success: "✅",
16
+ info: "🔍",
17
+ ongoing: "🔁",
18
+ warning: "⚠️",
19
+ error: "❌",
20
+ version: "💾",
21
+ broom: "🧹",
22
+ question: "❓",
23
+ bug: "🐛"
24
+ }.freeze
25
+
26
+ module_function
27
+
28
+ def setup_i18n!
29
+ return if defined?(@i18n_setup) && @i18n_setup
30
+
31
+ ::I18n.enforce_available_locales = false
32
+
33
+ base = File.expand_path("../../locales", __dir__)
34
+ paths = Dir[File.join(base, "*.yml")]
35
+ if paths.empty? && File.directory?(base)
36
+ paths = Dir.children(base).grep(/\.ya?ml\z/).map { |file| File.join(base, file) }
37
+ end
38
+ ::I18n.load_path |= paths
39
+ ::I18n.available_locales = SUPPORTED
40
+
41
+ default = ((ENV["VGH_LANG"] || ENV["LANG"] || "en")[0, 2] || "en").to_sym
42
+ ::I18n.default_locale = SUPPORTED.include?(default) ? default : :en
43
+
44
+ ::I18n.backend.load_translations
45
+ @i18n_setup = true
46
+ end
47
+
48
+ def set_locale!(lang)
49
+ setup_i18n!
50
+ sym = lang.to_s[0, 2].downcase.to_sym
51
+ unless SUPPORTED.include?(sym)
52
+ raise UnsupportedLocaleError,
53
+ "#{EMOJI[:error]} Unsupported language: #{sym}. Available: #{SUPPORTED.join(", ")}"
54
+ end
55
+ ::I18n.locale = sym
56
+ ::I18n.backend.load_translations
57
+ end
58
+
59
+ def setup_locale_from_config!(cfg)
60
+ lang = (cfg.respond_to?(:locale) ? cfg.locale : nil) || ENV.fetch("VGH_LANG", nil)
61
+ return unless lang
62
+
63
+ set_locale!(lang)
64
+ rescue UnsupportedLocaleError
65
+ set_locale!("en")
66
+ end
67
+
68
+ NS = "vgh"
69
+
70
+ def namespaced(key)
71
+ k = key.to_s
72
+ k.start_with?("#{NS}.") ? k : "#{NS}.#{k}"
73
+ end
74
+
75
+ def t(key, **opts)
76
+ setup_i18n!
77
+ ::I18n.t(namespaced(key), **opts)
78
+ end
79
+
80
+ def t_hash(key)
81
+ setup_i18n!
82
+ v = ::I18n.t(namespaced(key), default: {})
83
+ v.is_a?(Hash) ? v : {}
84
+ end
85
+
86
+ def our_key?(key)
87
+ OUR_NAMESPACES.any? { |ns| key.to_s.start_with?(ns) }
88
+ end
89
+
90
+ def e(key, no_emoji: false)
91
+ return "" if no_emoji || ENV["VGH_NO_EMOJI"] == "1"
92
+
93
+ EMOJI[key] || ""
94
+ end
95
+
96
+ def say(ui, msg)
97
+ ui&.info(msg) || puts(msg)
98
+ end
99
+
100
+ def warn(ui, msg)
101
+ ui&.warn(msg) || puts(msg)
102
+ end
103
+
104
+ def error(ui, msg)
105
+ ui&.error(msg) || warn(ui, msg)
106
+ end
107
+
108
+ def debug_enabled?
109
+ ENV["VGH_DEBUG"].to_s == "1"
110
+ end
111
+
112
+ def debug(ui, msg)
113
+ return unless debug_enabled?
114
+
115
+ say(ui, "#{e(:bug)} #{msg}")
116
+ end
117
+
118
+ def print_general_help
119
+ setup_i18n!
120
+ puts t("help.general_title")
121
+ t_hash("help.commands").each_value { |line| puts " #{line}" }
122
+ end
123
+
124
+ def print_topic_help(topic)
125
+ setup_i18n!
126
+ topic = topic.to_s.downcase.strip
127
+ return print_general_help if topic.empty?
128
+
129
+ body = t("help.topic.#{topic}", default: nil)
130
+ body ? puts(body) : print_general_help
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module VagrantGitHooks
6
+ module HookBuilder
7
+ SHEBANG = "#!/usr/bin/env sh"
8
+ BLOCK_START = "# >>> vagrant-git-hooks (managed) >>>"
9
+ BLOCK_STOP = "# <<< vagrant-git-hooks (managed) <<<"
10
+
11
+ module_function
12
+
13
+ # Builds a managed Git hook script from configured shell commands.
14
+ #
15
+ # @param name [String] Git hook name.
16
+ # @param commands [String, Array<String>] Shell commands written to the hook body.
17
+ # @param fail_on_error [Boolean] Whether to include `set -e`.
18
+ # @param newline [String] Line separator used in the generated script.
19
+ # @return [String] Complete hook script.
20
+ def script_for(name:, commands:, fail_on_error: true, newline: "\n")
21
+ lines = header(name, "do not edit manually")
22
+ lines << "set -e" if fail_on_error
23
+ lines.concat(body_lines(commands))
24
+ lines << BLOCK_STOP
25
+ lines.join(newline) + newline
26
+ end
27
+
28
+ # Wraps an existing hook body in plugin markers.
29
+ #
30
+ # Preserves user-provided hook bodies when adopting from hooks_source, but
31
+ # wraps them in markers so uninstall and status can manage them later.
32
+ #
33
+ # @param name [String] Git hook name.
34
+ # @param content [String] Existing hook script content.
35
+ # @param newline [String] Line separator used in the generated script.
36
+ # @return [String] Managed hook script.
37
+ def adopt_script(name:, content:, newline: "\n")
38
+ lines = content.to_s.split(/\r?\n/, -1)
39
+ lines.pop if lines.last == ""
40
+
41
+ shebang = lines.first&.start_with?("#!") ? lines.shift : SHEBANG
42
+ head = [shebang, BLOCK_START, "# Managed by Vagrant - adopted from hooks_source (hook: #{name})"]
43
+
44
+ (head + lines + [BLOCK_STOP]).join(newline) + newline
45
+ end
46
+
47
+ def managed?(content)
48
+ content.to_s.include?(BLOCK_START)
49
+ end
50
+
51
+ def header(name, note)
52
+ ts = begin
53
+ Time.now.utc.iso8601
54
+ rescue StandardError
55
+ Time.now.utc.to_s
56
+ end
57
+ [
58
+ SHEBANG,
59
+ BLOCK_START,
60
+ "# Managed by Vagrant - #{note} (hook: #{name})",
61
+ "# Generated: #{ts}"
62
+ ]
63
+ end
64
+
65
+ def body_lines(commands)
66
+ Array(commands).flat_map { |c| c.to_s.split("\n") }
67
+ end
68
+ end
69
+ end