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.
- checksums.yaml +4 -4
- data/README.adoc +682 -324
- data/docopslab-dev.gemspec +3 -4
- data/lib/docopslab/dev/cast_ops.rb +199 -0
- data/lib/docopslab/dev/config_manager.rb +6 -6
- data/lib/docopslab/dev/data_utils.rb +42 -0
- data/lib/docopslab/dev/docker_aware.rb +40 -0
- data/lib/docopslab/dev/file_utils.rb +18 -7
- data/lib/docopslab/dev/git_branch.rb +201 -0
- data/lib/docopslab/dev/git_hooks.rb +17 -11
- data/lib/docopslab/dev/initializer.rb +34 -11
- data/lib/docopslab/dev/library/cache.rb +167 -0
- data/lib/docopslab/dev/library/fetch.rb +209 -0
- data/lib/docopslab/dev/library.rb +341 -0
- data/lib/docopslab/dev/linters.rb +73 -15
- data/lib/docopslab/dev/manifest.rb +28 -0
- data/lib/docopslab/dev/paths.rb +0 -17
- data/lib/docopslab/dev/script_manager.rb +12 -6
- data/lib/docopslab/dev/skim.rb +109 -0
- data/lib/docopslab/dev/spell_check.rb +2 -2
- data/lib/docopslab/dev/sync_ops.rb +94 -33
- data/lib/docopslab/dev/tasks.rb +58 -18
- data/lib/docopslab/dev/version.rb +1 -1
- data/lib/docopslab/dev.rb +77 -36
- data/specs/data/default-manifest.yml +23 -5
- data/specs/data/library-index.yml +22 -0
- data/specs/data/manifest-schema.yaml +142 -4
- data/specs/data/tasks-def.yml +122 -10
- metadata +28 -73
- data/assets/config-packs/actionlint/base.yml +0 -13
- data/assets/config-packs/actionlint/project.yml +0 -13
- data/assets/config-packs/htmlproofer/base.yml +0 -27
- data/assets/config-packs/htmlproofer/project.yml +0 -25
- data/assets/config-packs/rubocop/base.yml +0 -130
- data/assets/config-packs/rubocop/project.yml +0 -8
- data/assets/config-packs/shellcheck/base.shellcheckrc +0 -14
- data/assets/config-packs/subtxt/ai-asciidoc-antipatterns.sub.txt +0 -11
- data/assets/config-packs/vale/asciidoc/ExplicitSectionIDs.yml +0 -8
- data/assets/config-packs/vale/asciidoc/ExtraLineBeforeLevel1.yml +0 -7
- data/assets/config-packs/vale/asciidoc/OneSentencePerLine.yml +0 -8
- data/assets/config-packs/vale/asciidoc/PreferSourceBlocks.yml +0 -8
- data/assets/config-packs/vale/asciidoc/ProperAdmonitions.yml +0 -8
- data/assets/config-packs/vale/asciidoc/ProperDLs.yml +0 -7
- data/assets/config-packs/vale/asciidoc/UncleanListStart.yml +0 -8
- data/assets/config-packs/vale/authoring/ButParagraph.yml +0 -8
- data/assets/config-packs/vale/authoring/ExNotEg.yml +0 -8
- data/assets/config-packs/vale/authoring/LiteralTerms.yml +0 -20
- data/assets/config-packs/vale/authoring/Spelling.yml +0 -679
- data/assets/config-packs/vale/base.ini +0 -38
- data/assets/config-packs/vale/config/scripts/ExplicitSectionIDs.tengo +0 -56
- data/assets/config-packs/vale/config/scripts/ExtraLineBeforeLevel1.tengo +0 -121
- data/assets/config-packs/vale/config/scripts/OneSentencePerLine.tengo +0 -53
- data/assets/config-packs/vale/project.ini +0 -5
- data/assets/hooks/pre-commit +0 -63
- data/assets/hooks/pre-push +0 -72
- data/assets/scripts/adoc_section_ids.rb +0 -50
- data/assets/scripts/build-common.sh +0 -193
- data/assets/scripts/build-docker.sh +0 -64
- data/assets/scripts/build.sh +0 -56
- data/assets/scripts/parse_jekyll_asciidoc_logs.rb +0 -467
- data/assets/templates/Gemfile +0 -7
- data/assets/templates/Rakefile +0 -3
- data/assets/templates/gitignore +0 -69
- data/assets/templates/jekyll-asciidoc-fix.prompt.yml +0 -17
- data/assets/templates/spellcheck.prompt.yml +0 -16
- data/docs/agent/AGENTS.md +0 -229
- data/docs/agent/index.md +0 -80
- data/docs/agent/missions/conduct-release.md +0 -224
- data/docs/agent/missions/setup-new-project.md +0 -250
- data/docs/agent/roles/devops-release-engineer.md +0 -152
- data/docs/agent/roles/docops-engineer.md +0 -193
- data/docs/agent/roles/planner-architect.md +0 -74
- data/docs/agent/roles/product-engineer.md +0 -153
- data/docs/agent/roles/product-manager.md +0 -130
- data/docs/agent/roles/project-manager.md +0 -139
- data/docs/agent/roles/qa-testing-engineer.md +0 -115
- data/docs/agent/roles/tech-docs-manager.md +0 -143
- data/docs/agent/roles/tech-writer.md +0 -163
- data/docs/agent/skills/asciidoc.md +0 -609
- data/docs/agent/skills/code-commenting.md +0 -347
- data/docs/agent/skills/fix-broken-links.md +0 -309
- data/docs/agent/skills/fix-jekyll-asciidoc-build-errors.md +0 -23
- data/docs/agent/skills/fix-spelling-issues.md +0 -13
- data/docs/agent/skills/git.md +0 -170
- data/docs/agent/skills/github-issues.md +0 -135
- data/docs/agent/skills/product-release-rollback-and-patching.md +0 -71
- data/docs/agent/skills/rake-cli-dev.md +0 -57
- data/docs/agent/skills/readme-driven-dev.md +0 -13
- data/docs/agent/skills/release-history.md +0 -29
- data/docs/agent/skills/ruby.md +0 -192
- data/docs/agent/skills/schemagraphy-sgyml.md +0 -18
- data/docs/agent/skills/tests-running.md +0 -25
- data/docs/agent/skills/tests-writing.md +0 -45
- data/docs/agent/skills/write-the-docs.md +0 -54
- data/docs/agent/topics/common-project-paths.md +0 -117
- data/docs/agent/topics/dev-tooling-usage.md +0 -202
- data/docs/agent/topics/devops-ci-cd.md +0 -55
- data/docs/agent/topics/product-docs-deployment.md +0 -25
data/docopslab-dev.gemspec
CHANGED
|
@@ -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('
|
|
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.
|
|
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(
|
|
8
|
+
base_config = File.join(Paths.config_vendor_dir, 'vale.ini')
|
|
9
9
|
project_config = '.config/vale.local.ini'
|
|
10
|
-
generated_config =
|
|
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(
|
|
37
|
+
base_config = File.join(Paths.config_vendor_dir, 'htmlproofer.yml')
|
|
38
38
|
project_config = '.config/htmlproofer.local.yml'
|
|
39
|
-
generated_config =
|
|
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
|
-
[
|
|
62
|
+
[Paths::CONFIG_FILES[:htmlproofer]]
|
|
63
63
|
end
|
|
64
|
-
config_paths << File.join(
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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}
|
|
13
|
-
hook_name = File.basename(template_path
|
|
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}
|
|
31
|
-
hook_name = File.basename(template_path
|
|
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
|
-
|
|
59
|
-
|
|
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}
|
|
102
|
-
hook_name = File.basename(template_path
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
141
|
+
Dev.hooks_dir
|
|
136
142
|
end
|
|
137
143
|
end
|
|
138
144
|
end
|