docopslab-dev 0.1.0 → 0.3.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.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +682 -324
  3. data/docopslab-dev.gemspec +3 -4
  4. data/lib/docopslab/dev/cast_ops.rb +199 -0
  5. data/lib/docopslab/dev/config_manager.rb +6 -6
  6. data/lib/docopslab/dev/data_utils.rb +42 -0
  7. data/lib/docopslab/dev/docker_aware.rb +40 -0
  8. data/lib/docopslab/dev/file_utils.rb +18 -7
  9. data/lib/docopslab/dev/git_branch.rb +201 -0
  10. data/lib/docopslab/dev/git_hooks.rb +17 -11
  11. data/lib/docopslab/dev/initializer.rb +34 -11
  12. data/lib/docopslab/dev/library/cache.rb +167 -0
  13. data/lib/docopslab/dev/library/fetch.rb +209 -0
  14. data/lib/docopslab/dev/library.rb +341 -0
  15. data/lib/docopslab/dev/linters.rb +73 -15
  16. data/lib/docopslab/dev/manifest.rb +28 -0
  17. data/lib/docopslab/dev/paths.rb +0 -17
  18. data/lib/docopslab/dev/script_manager.rb +12 -6
  19. data/lib/docopslab/dev/skim.rb +109 -0
  20. data/lib/docopslab/dev/spell_check.rb +2 -2
  21. data/lib/docopslab/dev/sync_ops.rb +94 -33
  22. data/lib/docopslab/dev/tasks.rb +58 -18
  23. data/lib/docopslab/dev/version.rb +1 -1
  24. data/lib/docopslab/dev.rb +77 -36
  25. data/specs/data/default-manifest.yml +23 -5
  26. data/specs/data/library-index.yml +22 -0
  27. data/specs/data/manifest-schema.yaml +142 -4
  28. data/specs/data/tasks-def.yml +122 -10
  29. metadata +28 -73
  30. data/assets/config-packs/actionlint/base.yml +0 -13
  31. data/assets/config-packs/actionlint/project.yml +0 -13
  32. data/assets/config-packs/htmlproofer/base.yml +0 -27
  33. data/assets/config-packs/htmlproofer/project.yml +0 -25
  34. data/assets/config-packs/rubocop/base.yml +0 -130
  35. data/assets/config-packs/rubocop/project.yml +0 -8
  36. data/assets/config-packs/shellcheck/base.shellcheckrc +0 -14
  37. data/assets/config-packs/subtxt/ai-asciidoc-antipatterns.sub.txt +0 -11
  38. data/assets/config-packs/vale/asciidoc/ExplicitSectionIDs.yml +0 -8
  39. data/assets/config-packs/vale/asciidoc/ExtraLineBeforeLevel1.yml +0 -7
  40. data/assets/config-packs/vale/asciidoc/OneSentencePerLine.yml +0 -8
  41. data/assets/config-packs/vale/asciidoc/PreferSourceBlocks.yml +0 -8
  42. data/assets/config-packs/vale/asciidoc/ProperAdmonitions.yml +0 -8
  43. data/assets/config-packs/vale/asciidoc/ProperDLs.yml +0 -7
  44. data/assets/config-packs/vale/asciidoc/UncleanListStart.yml +0 -8
  45. data/assets/config-packs/vale/authoring/ButParagraph.yml +0 -8
  46. data/assets/config-packs/vale/authoring/ExNotEg.yml +0 -8
  47. data/assets/config-packs/vale/authoring/LiteralTerms.yml +0 -20
  48. data/assets/config-packs/vale/authoring/Spelling.yml +0 -679
  49. data/assets/config-packs/vale/base.ini +0 -38
  50. data/assets/config-packs/vale/config/scripts/ExplicitSectionIDs.tengo +0 -56
  51. data/assets/config-packs/vale/config/scripts/ExtraLineBeforeLevel1.tengo +0 -121
  52. data/assets/config-packs/vale/config/scripts/OneSentencePerLine.tengo +0 -53
  53. data/assets/config-packs/vale/project.ini +0 -5
  54. data/assets/hooks/pre-commit +0 -63
  55. data/assets/hooks/pre-push +0 -72
  56. data/assets/scripts/adoc_section_ids.rb +0 -50
  57. data/assets/scripts/build-common.sh +0 -193
  58. data/assets/scripts/build-docker.sh +0 -64
  59. data/assets/scripts/build.sh +0 -56
  60. data/assets/scripts/parse_jekyll_asciidoc_logs.rb +0 -467
  61. data/assets/templates/Gemfile +0 -7
  62. data/assets/templates/Rakefile +0 -3
  63. data/assets/templates/gitignore +0 -69
  64. data/assets/templates/jekyll-asciidoc-fix.prompt.yml +0 -17
  65. data/assets/templates/spellcheck.prompt.yml +0 -16
  66. data/docs/agent/AGENTS.md +0 -229
  67. data/docs/agent/index.md +0 -80
  68. data/docs/agent/missions/conduct-release.md +0 -224
  69. data/docs/agent/missions/setup-new-project.md +0 -250
  70. data/docs/agent/roles/devops-release-engineer.md +0 -152
  71. data/docs/agent/roles/docops-engineer.md +0 -193
  72. data/docs/agent/roles/planner-architect.md +0 -74
  73. data/docs/agent/roles/product-engineer.md +0 -153
  74. data/docs/agent/roles/product-manager.md +0 -130
  75. data/docs/agent/roles/project-manager.md +0 -139
  76. data/docs/agent/roles/qa-testing-engineer.md +0 -115
  77. data/docs/agent/roles/tech-docs-manager.md +0 -143
  78. data/docs/agent/roles/tech-writer.md +0 -163
  79. data/docs/agent/skills/asciidoc.md +0 -609
  80. data/docs/agent/skills/code-commenting.md +0 -347
  81. data/docs/agent/skills/fix-broken-links.md +0 -309
  82. data/docs/agent/skills/fix-jekyll-asciidoc-build-errors.md +0 -23
  83. data/docs/agent/skills/fix-spelling-issues.md +0 -13
  84. data/docs/agent/skills/git.md +0 -170
  85. data/docs/agent/skills/github-issues.md +0 -135
  86. data/docs/agent/skills/product-release-rollback-and-patching.md +0 -71
  87. data/docs/agent/skills/rake-cli-dev.md +0 -57
  88. data/docs/agent/skills/readme-driven-dev.md +0 -13
  89. data/docs/agent/skills/release-history.md +0 -29
  90. data/docs/agent/skills/ruby.md +0 -192
  91. data/docs/agent/skills/schemagraphy-sgyml.md +0 -18
  92. data/docs/agent/skills/tests-running.md +0 -25
  93. data/docs/agent/skills/tests-writing.md +0 -45
  94. data/docs/agent/skills/write-the-docs.md +0 -54
  95. data/docs/agent/topics/common-project-paths.md +0 -117
  96. data/docs/agent/topics/dev-tooling-usage.md +0 -202
  97. data/docs/agent/topics/devops-ci-cd.md +0 -55
  98. data/docs/agent/topics/product-docs-deployment.md +0 -25
@@ -20,12 +20,10 @@ Gem::Specification.new do |spec|
20
20
  spec.metadata['changelog_uri'] = 'https://github.com/DocOps/lab/blob/main/gems/docopslab-dev/README.adoc'
21
21
  spec.metadata['rubygems_mfa_required'] = 'true'
22
22
 
23
- spec.files = Dir.glob('{lib,config-packs,hooks,docs,assets}/**/*') +
23
+ spec.files = Dir.glob('lib/**/*') +
24
24
  %w[README.adoc LICENSE docopslab-dev.gemspec] +
25
25
  Dir.glob('specs/data/*')
26
26
 
27
- spec.bindir = 'exe'
28
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
27
  spec.require_paths = ['lib']
30
28
 
31
29
  # Core runtime dependencies
@@ -34,6 +32,7 @@ Gem::Specification.new do |spec|
34
32
  spec.add_dependency 'yaml', '~> 0.2'
35
33
 
36
34
  # Code quality and linting
35
+ spec.add_dependency 'asciisourcerer', '~> 0.2'
37
36
  spec.add_dependency 'debride', '~> 1.13'
38
37
  spec.add_dependency 'fasterer', '~> 0.11'
39
38
  spec.add_dependency 'flog', '~> 4.8'
@@ -48,7 +47,7 @@ Gem::Specification.new do |spec|
48
47
  spec.add_dependency 'bundler-audit', '~> 0.9'
49
48
 
50
49
  # Testing and coverage
51
- spec.add_dependency 'html-proofer', '~> 5.0'
50
+ spec.add_dependency 'html-proofer', '~> 5.2'
52
51
  spec.add_dependency 'inch', '~> 0.8'
53
52
  spec.add_dependency 'simplecov', '~> 0.22'
54
53
 
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'sourcerer/sync'
5
+
6
+ module DocOpsLab
7
+ module Dev
8
+ # Sync/Cast operations: synchronize canonical blocks from prime templates
9
+ # into project-local target files using Sourcerer::Sync.
10
+ #
11
+ # Prime templates define canonical (universal-prefixed) blocks.
12
+ # Target files receive those blocks on each sync, preserving all local content.
13
+ # On first-time init the whole prime is rendered and written as the new target.
14
+ #
15
+ # Configuration is driven by <tt>templates:</tt> in the project manifest.
16
+ # The list of template-to-target procedures lives under <tt>templates.manifest</tt>.
17
+ module CastOps
18
+ class << self
19
+ # Sync canonical blocks from prime templates into all configured target files.
20
+ #
21
+ # @param context [DocOpsLab::Dev] caller context (for manifest access)
22
+ # @param target_filter [String, nil] restrict to entries whose target matches
23
+ # @param dry_run [Boolean] compute diff but do not write
24
+ # @return [Hash{String => Sourcerer::Sync::Cast::CastResult}]
25
+ def sync_cast_targets context, target_filter: nil, dry_run: false
26
+ castings, global_data = load_castings(context, target_filter)
27
+ return {} unless castings
28
+
29
+ label = dry_run ? '🔍 Dry-run: diffing cast targets...' : '🔄 Syncing cast targets...'
30
+ puts label
31
+
32
+ results = {}
33
+ castings.each do |entry|
34
+ prime_path = resolve_prime(entry)
35
+ next unless prime_path
36
+
37
+ target_path = entry['target']
38
+
39
+ unless File.exist?(target_path)
40
+ puts " âš ī¸ Target not found: #{target_path} (run labdev:init:templates to bootstrap)"
41
+ next
42
+ end
43
+
44
+ data = build_data(global_data, entry)
45
+ canonical_prefix = entry.fetch('canonical_prefix', 'universal-')
46
+
47
+ result = Sourcerer::Sync.sync(
48
+ prime_path,
49
+ target_path,
50
+ data: data,
51
+ canonical_prefix: canonical_prefix,
52
+ dry_run: dry_run)
53
+
54
+ results[target_path] = result
55
+ report_result(result, dry_run: dry_run)
56
+ end
57
+
58
+ puts "✅ Synced #{results.count { |_, r| r.applied_changes.any? }} cast target(s)" unless dry_run
59
+ results
60
+ end
61
+
62
+ # Bootstrap new target files from prime templates.
63
+ # Skips targets that already exist (use sync to update those).
64
+ #
65
+ # @param context [DocOpsLab::Dev] caller context
66
+ # @param target_filter [String, nil] restrict to entries whose target matches
67
+ # @return [Hash{String => Sourcerer::Sync::Cast::CastResult}]
68
+ def init_cast_targets context, target_filter: nil
69
+ castings, global_data = load_castings(context, target_filter)
70
+ return {} unless castings
71
+
72
+ puts '🆕 Initializing cast targets...'
73
+
74
+ results = {}
75
+ castings.each do |entry|
76
+ prime_path = resolve_prime(entry)
77
+ next unless prime_path
78
+
79
+ target_path = entry['target']
80
+
81
+ if File.exist?(target_path)
82
+ puts " â­ī¸ Skipped #{target_path} (already exists; use labdev:sync:templates to update)"
83
+ next
84
+ end
85
+
86
+ data = build_data(global_data, entry)
87
+ result = Sourcerer::Sync.init(prime_path, target_path, data: data)
88
+
89
+ results[target_path] = result
90
+ report_result(result, init: true)
91
+ end
92
+
93
+ puts "✅ Initialized #{results.size} cast target(s)"
94
+ results
95
+ end
96
+
97
+ private
98
+
99
+ # Returns [entries_array, global_data_hash] from the manifest.
100
+ # When target_filter is given, entries is restricted to that single entry.
101
+ def load_castings context, target_filter
102
+ manifest = context.load_manifest
103
+ unless manifest
104
+ puts "❌ No manifest found at #{MANIFEST_PATH}"
105
+ return nil
106
+ end
107
+
108
+ templates_cfg = manifest['templates']
109
+ unless templates_cfg.is_a?(Hash)
110
+ puts 'âš ī¸ No templates section configured in manifest'
111
+ return nil
112
+ end
113
+
114
+ castings = templates_cfg['manifest']
115
+ unless castings.is_a?(Array) && castings.any?
116
+ puts 'âš ī¸ No entries configured under templates.manifest:'
117
+ return nil
118
+ end
119
+
120
+ global_data = templates_cfg['data'] || {}
121
+
122
+ if target_filter
123
+ castings = castings.select { |e| e['target'] == target_filter }
124
+ if castings.empty?
125
+ puts "❌ No casting matched target: #{target_filter}"
126
+ return nil
127
+ end
128
+ end
129
+
130
+ [castings, global_data]
131
+ end
132
+
133
+ # Build the Liquid data hash for a casting.
134
+ #
135
+ # All template variables live under the top-level +data+ key:
136
+ # data.project.attributes — document attributes from README.adoc
137
+ # data.variables.<key> — merged manifest variables (global + per-entry)
138
+ #
139
+ # Precedence: data.project.attributes < global variables < per-entry variables
140
+ def build_data global_data, entry
141
+ inner = { 'project' => { 'attributes' => DataUtils.project_attributes } }
142
+
143
+ vars = {}
144
+ vars.merge!(global_data['variables'] || {})
145
+
146
+ casting_data = entry['data'] || {}
147
+ vars.merge!(casting_data['variables'] || {})
148
+
149
+ inner['variables'] = vars
150
+ { 'data' => inner }
151
+ end
152
+
153
+ # Resolve the prime template path from a casting entry.
154
+ # Uses 'source' key for library-relative paths.
155
+ def resolve_prime entry
156
+ if entry['source']
157
+ lib_root = Library.root
158
+ unless lib_root
159
+ puts ' ❌ Library not available; run labdev:sync:library to fetch.'
160
+ return nil
161
+ end
162
+ path = File.join(lib_root, entry['source'])
163
+ return path if File.exist?(path)
164
+
165
+ puts " ❌ Library source not found: #{entry['source']} (run labdev:sync:library)"
166
+ else
167
+ puts " ❌ Entry for '#{entry['target']}' has no 'source' key"
168
+ end
169
+ nil
170
+ end
171
+
172
+ def report_result result, dry_run: false, init: false
173
+ target = result.target_path
174
+
175
+ result.errors.each { |e| puts " ❌ #{target}: #{e}" }
176
+ result.warnings.each { |w| puts " âš ī¸ #{target}: #{w}" }
177
+
178
+ return if result.errors.any?
179
+
180
+ if init
181
+ puts " ✅ Initialized #{target}"
182
+ elsif dry_run
183
+ if result.diff && !result.diff.empty?
184
+ puts " 📋 #{target}: differences found"
185
+ result.diff.lines.first(10).each { |l| print " #{l}" }
186
+ puts ''
187
+ else
188
+ puts " ✓ #{target}: up to date"
189
+ end
190
+ elsif result.applied_changes.any?
191
+ puts " ✅ #{target}: updated blocks: #{result.applied_changes.join(', ')}"
192
+ else
193
+ puts " ✓ #{target}: up to date"
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -5,9 +5,9 @@ module DocOpsLab
5
5
  module ConfigManager
6
6
  class << self
7
7
  def generate_vale_config _context, style_override: nil
8
- base_config = File.join(CONFIG_VENDOR_DIR, 'vale.ini')
8
+ base_config = File.join(Paths.config_vendor_dir, 'vale.ini')
9
9
  project_config = '.config/vale.local.ini'
10
- generated_config = CONFIG_PATHS[:vale]
10
+ generated_config = Paths::CONFIG_FILES[:vale]
11
11
 
12
12
  return false unless File.exist?(base_config)
13
13
 
@@ -34,9 +34,9 @@ module DocOpsLab
34
34
  end
35
35
 
36
36
  def generate_htmlproofer_config _context
37
- base_config = File.join(CONFIG_VENDOR_DIR, 'htmlproofer.yml')
37
+ base_config = File.join(Paths.config_vendor_dir, 'htmlproofer.yml')
38
38
  project_config = '.config/htmlproofer.local.yml'
39
- generated_config = CONFIG_PATHS[:htmlproofer]
39
+ generated_config = Paths::CONFIG_FILES[:htmlproofer]
40
40
 
41
41
  return false unless File.exist?(base_config)
42
42
 
@@ -59,9 +59,9 @@ module DocOpsLab
59
59
  config_paths = if config_path && File.exist?(config_path)
60
60
  [config_path]
61
61
  else
62
- [CONFIG_PATHS[:htmlproofer]]
62
+ [Paths::CONFIG_FILES[:htmlproofer]]
63
63
  end
64
- config_paths << File.join(CONFIG_VENDOR_DIR, 'htmlproofer.yml') unless policy == 'replace'
64
+ config_paths << File.join(Paths.config_vendor_dir, 'htmlproofer.yml') unless policy == 'replace'
65
65
  config_path = config_paths.find { |path| File.exist?(path) }
66
66
 
67
67
  return unless config_path
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocOpsLab
4
+ module Dev
5
+ # Utilities for loading and caching project-level data for use in
6
+ # Liquid template rendering (Sync/Cast) and other data-driven operations.
7
+ module DataUtils
8
+ class << self
9
+ # Lazily load and cache the README.adoc document attributes.
10
+ #
11
+ # Available in Liquid templates as +data.project.attributes.<key>+.
12
+ # Asciidoctor built-in attributes are filtered out; only user-defined
13
+ # string attributes are returned.
14
+ #
15
+ # @return [Hash{String => String}]
16
+ def project_attributes
17
+ return @project_attributes if defined?(@project_attributes)
18
+
19
+ readme = 'README.adoc'
20
+ unless File.exist?(readme)
21
+ @project_attributes = {}
22
+ return @project_attributes
23
+ end
24
+
25
+ require 'sourcerer/asciidoc'
26
+ raw = Sourcerer::AsciiDoc.load_attributes(readme)
27
+ @project_attributes = raw.select do |k, v|
28
+ v.is_a?(String) && !v.empty? && !k.start_with?('asciidoctor')
29
+ end
30
+ rescue StandardError => e
31
+ warn "âš ī¸ Could not load README.adoc attributes: #{e.message}"
32
+ @project_attributes = {}
33
+ end
34
+
35
+ # Reset the cached project attributes (useful in tests or after file changes).
36
+ def reset_project_attributes!
37
+ remove_instance_variable(:@project_attributes) if defined?(@project_attributes)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocOpsLab
4
+ module Dev
5
+ # Detects and provides utilities for Docker container environments.
6
+ module DockerAware
7
+ class << self
8
+ # True if running inside a Docker container.
9
+ # Checks:
10
+ # 1. DOCOPSLAB_IN_DOCKER environment variable (set in Dockerfile)
11
+ # 2. /.dockerenv marker file (standard Docker indicator)
12
+ def running_in_docker?
13
+ ENV['DOCOPSLAB_IN_DOCKER'] == 'true' || File.exist?('/.dockerenv')
14
+ end
15
+
16
+ # True if Docker but without access to host's cache directory.
17
+ # This is the case when user runs: docker run -v "$(pwd):/workspace" ...
18
+ # without explicitly mounting ~/.cache/docopslab
19
+ def docker_without_cache?
20
+ running_in_docker? && !cache_mount_accessible?
21
+ end
22
+
23
+ # True if the Docker container can access the host's cache mount.
24
+ # Checks if /home/docops/.cache/docopslab exists and is readable.
25
+ def cache_mount_accessible?
26
+ cache_path = File.expand_path('~/.cache/docopslab')
27
+ File.exist?(cache_path) && File.directory?(cache_path) && File.readable?(cache_path)
28
+ rescue StandardError
29
+ false
30
+ end
31
+
32
+ # Workspace-relative cache path for Docker-only users.
33
+ # Returns path like /workspace/.docopslab-cache/
34
+ def workspace_cache_path
35
+ File.join('/workspace', '.docopslab-cache')
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'pathname'
4
+ require 'sourcerer/util/pathifier'
4
5
 
5
6
  module DocOpsLab
6
7
  module Dev
@@ -30,6 +31,18 @@ module DocOpsLab
30
31
  scripts << file if File.executable?(file) || FileUtilities.shell_shebang?(file)
31
32
  end
32
33
  end
34
+
35
+ # Also pick up extensionless files with a Bash-implying shebang
36
+ Dir.glob('**/*').each do |file|
37
+ next unless File.file?(file)
38
+ next unless File.extname(file).empty?
39
+ next if file.include?('/.vendor/')
40
+ next if file.include?('/node_modules/')
41
+ next unless FileUtilities.git_tracked_or_staged?(file)
42
+
43
+ scripts << file if FileUtilities.shell_shebang?(file)
44
+ end
45
+
33
46
  scripts.uniq.sort
34
47
  end
35
48
 
@@ -54,15 +67,13 @@ module DocOpsLab
54
67
 
55
68
  return [] unless lint_paths
56
69
 
70
+ pwd = Pathname.pwd
57
71
  files = []
58
72
  lint_paths.each do |path|
59
- # If path is a directory, search recursively. Otherwise, it's a glob.
60
- glob_pattern = File.directory?(path) ? File.join(path, '**', '*') : path
61
- Dir.glob(glob_pattern).each do |file|
62
- next unless File.file?(file)
63
-
64
- # Normalize path by removing ./ prefix for consistent pattern matching
65
- normalized = file.sub(%r{^\./}, '')
73
+ Sourcerer::Util::Pathifier.match(path).enum.each do |file|
74
+ # Normalize to a relative path for consistent pattern matching.
75
+ # Pathifier may return absolute paths, so we relativize unconditionally.
76
+ normalized = Pathname.new(file).expand_path.relative_path_from(pwd).to_s
66
77
  files << normalized
67
78
  end
68
79
  end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ module DocOpsLab
5
+ module Dev
6
+ # Git branch safety utilities for Rake tasks
7
+ #
8
+ # Provides methods to safely handle branch switching by checking for:
9
+ # - Uncommitted changes (modified tracked files)
10
+ # - Untracked files that would conflict with target branch
11
+ #
12
+ # @example Basic usage
13
+ # include DocOpsLab::Dev::GitBranch
14
+ #
15
+ # current = git_current_branch
16
+ # if git_safe_to_switch?('gh-pages')
17
+ # system("git checkout gh-pages")
18
+ # else
19
+ # exit 1
20
+ # end
21
+ #
22
+ # @example With custom error handling
23
+ # git_ensure_clean_switch!('deploy-branch') do |conflicts|
24
+ # puts "Found conflicts: #{conflicts.join(', ')}"
25
+ # end
26
+ module GitBranch
27
+ # Get the current branch name
28
+ #
29
+ # @return [String] the current branch name
30
+ # @raise [RuntimeError] if not in a git repository
31
+ def git_current_branch
32
+ branch = `git branch --show-current 2>&1`.strip
33
+ raise 'Not in a git repository' if $CHILD_STATUS.exitstatus != 0
34
+
35
+ branch
36
+ end
37
+
38
+ # Check if working directory has uncommitted changes
39
+ #
40
+ # Uses `git status --porcelain` to detect:
41
+ # - Modified tracked files
42
+ # - Staged changes
43
+ # - Deleted files
44
+ #
45
+ # @return [Boolean] true if there are uncommitted changes
46
+ def git_has_uncommitted_changes?
47
+ !`git status --porcelain`.strip.empty?
48
+ end
49
+
50
+ # Get list of untracked files in working directory
51
+ #
52
+ # @return [Array<String>] list of untracked file paths
53
+ def git_untracked_files
54
+ `git ls-files --others --exclude-standard`.strip.split("\n")
55
+ end
56
+
57
+ # Get list of files in target branch
58
+ #
59
+ # @param branch [String] the target branch name
60
+ # @return [Array<String>] list of file paths in the branch
61
+ # @return [nil] if branch doesn't exist
62
+ def git_files_in_branch branch
63
+ # Check if branch exists
64
+ result = `git rev-parse --verify #{branch} 2>/dev/null`.strip
65
+ return nil if result.empty?
66
+
67
+ # List all files in the branch
68
+ `git ls-tree -r #{branch} --name-only`.strip.split("\n")
69
+ end
70
+
71
+ # Find untracked files that would conflict with target branch
72
+ #
73
+ # An untracked file conflicts if:
74
+ # - It exists in the working directory (untracked)
75
+ # - A file with the same path exists in the target branch
76
+ # - Switching branches would require overwriting the untracked file
77
+ #
78
+ # @param branch [String] the target branch name
79
+ # @return [Array<String>] list of conflicting file paths
80
+ # @return [nil] if target branch doesn't exist
81
+ def git_conflicting_files branch
82
+ branch_files = git_files_in_branch(branch)
83
+ return nil if branch_files.nil?
84
+
85
+ untracked = git_untracked_files
86
+ untracked & branch_files # Intersection
87
+ end
88
+
89
+ # Check if it's safe to switch to target branch
90
+ #
91
+ # Safe to switch if:
92
+ # - No uncommitted changes in tracked files
93
+ # - No untracked files that would conflict with target branch
94
+ #
95
+ # @param branch [String] the target branch name
96
+ # @param verbose [Boolean] whether to print detailed messages
97
+ # @return [Boolean] true if safe to switch
98
+ def git_safe_to_switch? branch, verbose: true
99
+ # Check for uncommitted changes
100
+ if git_has_uncommitted_changes?
101
+ puts '❌ You have uncommitted changes. Please commit or stash them first.' if verbose
102
+ puts "💡 Run 'git status' to see changes." if verbose
103
+ return false
104
+ end
105
+
106
+ # Check for conflicting untracked files
107
+ conflicts = git_conflicting_files(branch)
108
+
109
+ if conflicts.nil?
110
+ puts "❌ Target branch '#{branch}' does not exist." if verbose
111
+ return false
112
+ end
113
+
114
+ unless conflicts.empty?
115
+ if verbose
116
+ puts "❌ Untracked files would conflict with branch '#{branch}':"
117
+ conflicts.each { |f| puts " - #{f}" }
118
+ puts '💡 Commit these files or remove them before switching branches.'
119
+ end
120
+ return false
121
+ end
122
+
123
+ true
124
+ end
125
+
126
+ # Ensure it's safe to switch branches, exit if not
127
+ #
128
+ # This is a convenience method that calls git_safe_to_switch?
129
+ # and exits with status 1 if not safe.
130
+ #
131
+ # @param branch [String] the target branch name
132
+ # @param verbose [Boolean] whether to print detailed messages
133
+ # @yield [conflicts] optional block to run if conflicts found
134
+ # @yieldparam conflicts [Array<String>] list of conflicting files
135
+ # @return [void]
136
+ def git_ensure_clean_switch! branch, verbose: true
137
+ return if git_safe_to_switch?(branch, verbose: verbose)
138
+
139
+ # If block given, call it with conflicts before exiting
140
+ if block_given?
141
+ conflicts = git_conflicting_files(branch) || []
142
+ yield(conflicts)
143
+ end
144
+
145
+ exit 1
146
+ end
147
+
148
+ # Execute a block on a different branch, then return to original
149
+ #
150
+ # Safely switches to target branch, executes block, then returns
151
+ # to original branch. Ensures clean state before switching.
152
+ #
153
+ # @param branch [String] the target branch to switch to
154
+ # @param verbose [Boolean] whether to print detailed messages
155
+ # @yield the block to execute on the target branch
156
+ # @return [Object] the return value of the block
157
+ # @raise [RuntimeError] if branch switch fails or block raises
158
+ #
159
+ # @example
160
+ # git_on_branch('gh-pages') do
161
+ # # Do work on gh-pages
162
+ # FileUtils.cp_r('_site/*', '.')
163
+ # end
164
+ # # Automatically returns to original branch
165
+ def git_on_branch branch, verbose: true
166
+ original_branch = git_current_branch
167
+
168
+ # Safety check
169
+ git_ensure_clean_switch!(branch, verbose: verbose)
170
+
171
+ begin
172
+ puts "đŸ“Ļ Switching to #{branch} branch..." if verbose
173
+ system("git checkout #{branch}") or raise "Failed to checkout #{branch}"
174
+
175
+ # Execute the block
176
+ result = yield
177
+
178
+ result
179
+ ensure
180
+ # Always return to original branch
181
+ if git_current_branch != original_branch
182
+ puts "🔄 Returning to #{original_branch} branch..." if verbose
183
+ system("git checkout #{original_branch}")
184
+ end
185
+ end
186
+ end
187
+
188
+ # Get a summary of git working directory status
189
+ #
190
+ # @return [Hash] hash with :branch, :clean, :modified_count, :untracked_count
191
+ def git_status_summary
192
+ {
193
+ branch: git_current_branch,
194
+ clean: !git_has_uncommitted_changes?,
195
+ modified_count: `git status --porcelain`.strip.lines.count,
196
+ untracked_count: git_untracked_files.count
197
+ }
198
+ end
199
+ end
200
+ end
201
+ end
@@ -9,8 +9,8 @@ module DocOpsLab
9
9
  def install_missing_hooks
10
10
  return unless Dir.exist?(hooks_dir)
11
11
 
12
- Dir.glob("#{hooks_template_dir}/*.sh").each do |template_path|
13
- hook_name = File.basename(template_path, '.sh')
12
+ Dir.glob("#{hooks_template_dir}/*").each do |template_path|
13
+ hook_name = File.basename(template_path)
14
14
  hook_path = File.join(hooks_dir, hook_name)
15
15
 
16
16
  next if File.exist?(hook_path)
@@ -27,8 +27,8 @@ module DocOpsLab
27
27
 
28
28
  updates_available = false
29
29
 
30
- Dir.glob("#{hooks_template_dir}/*.sh").each do |template_path|
31
- hook_name = File.basename(template_path, '.sh')
30
+ Dir.glob("#{hooks_template_dir}/*").each do |template_path|
31
+ hook_name = File.basename(template_path)
32
32
  hook_path = File.join(hooks_dir, hook_name)
33
33
 
34
34
  if File.exist?(hook_path)
@@ -55,8 +55,10 @@ module DocOpsLab
55
55
  def update_hooks_interactive
56
56
  return puts 'â„šī¸ No .git directory found' unless Dir.exist?(hooks_dir)
57
57
 
58
- Dir.glob("#{hooks_template_dir}/*.sh").each do |template_path|
59
- hook_name = File.basename(template_path, '.sh')
58
+ hooks_to_update = []
59
+
60
+ Dir.glob("#{hooks_template_dir}/*").each do |template_path|
61
+ hook_name = File.basename(template_path)
60
62
  hook_path = File.join(hooks_dir, hook_name)
61
63
 
62
64
  if File.exist?(hook_path)
@@ -65,6 +67,8 @@ module DocOpsLab
65
67
 
66
68
  next if template_content == current_content
67
69
 
70
+ hooks_to_update << hook_name
71
+
68
72
  puts "🔄 #{hook_name} hook has updates available"
69
73
  puts "Current file exists at: #{hook_path}"
70
74
 
@@ -92,14 +96,15 @@ module DocOpsLab
92
96
  end
93
97
  end
94
98
  end
99
+ puts '✅ No hooks need updating.' if hooks_to_update.empty?
95
100
  end
96
101
 
97
102
  def list_hook_templates
98
103
  puts '📋 Available git hook templates:'
99
104
  puts ''
100
105
 
101
- Dir.glob("#{hooks_template_dir}/*.sh").each do |template_path|
102
- hook_name = File.basename(template_path, '.sh')
106
+ Dir.glob("#{hooks_template_dir}/*").each do |template_path|
107
+ hook_name = File.basename(template_path)
103
108
  hook_path = File.join(hooks_dir, hook_name)
104
109
 
105
110
  status = nil
@@ -120,7 +125,7 @@ module DocOpsLab
120
125
  ''
121
126
  end
122
127
 
123
- puts " #{hook_name}: #{status}"
128
+ puts " - #{hook_name.ljust(15)} (#{status})"
124
129
  puts " #{description}" unless description.empty?
125
130
  end
126
131
  end
@@ -128,11 +133,12 @@ module DocOpsLab
128
133
  private
129
134
 
130
135
  def hooks_template_dir
131
- HOOKS_SOURCE_DIR
136
+ Library.ensure_available!
137
+ Library.resolve('hooks') || raise('Library hooks directory not found; run `labdev:sync:library`.')
132
138
  end
133
139
 
134
140
  def hooks_dir
135
- HOOKS_DIR
141
+ Dev.hooks_dir
136
142
  end
137
143
  end
138
144
  end