diffstitch 1.0.3

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.
@@ -0,0 +1,218 @@
1
+ /* ── Variables ──────────────────────────────────────────────────── */
2
+ :root {
3
+ --gh-canvas: #ffffff;
4
+ --gh-surface: #f6f8fa;
5
+ --gh-border: #d0d7de;
6
+ --gh-text: #1f2328;
7
+ --gh-muted: #57606a;
8
+ --gh-link: #0969da;
9
+ --gh-nav-bg: #24292f;
10
+ --gh-nav-border: #30363d;
11
+ --gh-nav-text: #f0f6fc;
12
+
13
+ --gh-add-bg: #e6ffec;
14
+ --gh-add-num-bg: #abf2bc;
15
+ --gh-add-num-text: #033a16;
16
+ --gh-add-num-border:#56d364;
17
+
18
+ --gh-del-bg: #ffebe9;
19
+ --gh-del-num-bg: rgba(255,129,130,.4);
20
+ --gh-del-num-text: #82071e;
21
+ --gh-del-num-border:rgba(255,129,130,.6);
22
+
23
+ --gh-hunk-bg: #ddf4ff;
24
+ --gh-hunk-text: #0550ae;
25
+ --gh-hunk-border: rgba(84,174,255,.3);
26
+
27
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
28
+ }
29
+
30
+ /* ── Page shell ─────────────────────────────────────────────────── */
31
+
32
+ body { background: var(--gh-canvas); color: var(--gh-text); }
33
+
34
+ /* ── Top nav (GitHub dark bar) ──────────────────────────────────── */
35
+
36
+ .topnav {
37
+ background: var(--gh-nav-bg);
38
+ border-bottom: 1px solid var(--gh-nav-border);
39
+ }
40
+
41
+ .topnav h1 { color: var(--gh-nav-text); }
42
+
43
+ .branch-select {
44
+ background-color: #21262d !important;
45
+ color: var(--gh-nav-text) !important;
46
+ border-color: #30363d !important;
47
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%238b949e' d='M1 1l5 5 5-5'/%3E%3C/svg%3E") !important;
48
+ }
49
+
50
+ .branch-select:focus {
51
+ border-color: #58a6ff !important;
52
+ box-shadow: 0 0 0 3px rgba(88,166,255,.25) !important;
53
+ }
54
+
55
+ /* ── Panel chrome ───────────────────────────────────────────────── */
56
+
57
+ .panel-left { border-right: 1px solid var(--gh-border); }
58
+
59
+ .panel-header {
60
+ padding: .375rem .875rem;
61
+ background: var(--gh-surface);
62
+ border-bottom: 1px solid var(--gh-border);
63
+ font-family: var(--mono);
64
+ font-size: 12px;
65
+ flex-shrink: 0;
66
+ }
67
+
68
+ .panel-header .fw-semibold { color: var(--gh-link); }
69
+
70
+ .ds-badge {
71
+ display: inline-block;
72
+ padding: 1px 7px;
73
+ font-size: 11px;
74
+ font-family: var(--mono);
75
+ background: var(--gh-surface);
76
+ color: var(--gh-muted);
77
+ border: 1px solid var(--gh-border);
78
+ border-radius: 2em;
79
+ }
80
+
81
+ /* ── Empty state ────────────────────────────────────────────────── */
82
+
83
+ .empty-state {
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ height: 200px;
88
+ font-family: var(--mono);
89
+ font-size: 13px;
90
+ font-style: italic;
91
+ color: var(--gh-muted);
92
+ }
93
+
94
+ /* ── Scrollbar ──────────────────────────────────────────────────── */
95
+
96
+ .panel-content::-webkit-scrollbar { width: 8px; height: 8px; }
97
+ .panel-content::-webkit-scrollbar-track { background: var(--gh-canvas); }
98
+ .panel-content::-webkit-scrollbar-thumb { background: var(--gh-border); border-radius: 4px; }
99
+ .panel-content::-webkit-scrollbar-thumb:hover { background: var(--gh-muted); }
100
+
101
+ /* ── diff2html overrides ── GitHub colour palette ───────────────── */
102
+
103
+ /* Strip diff2html's own borders/margins so our panel chrome takes over */
104
+ .panel-content .d2h-wrapper { background: var(--gh-canvas); }
105
+
106
+ .panel-content .d2h-file-wrapper {
107
+ border: none;
108
+ border-bottom: 1px solid var(--gh-border);
109
+ border-radius: 0;
110
+ margin: 0;
111
+ background: var(--gh-canvas);
112
+ }
113
+
114
+ /* Force single-column layout (we already split the two d2h sides ourselves) */
115
+ .panel-content .d2h-files-diff { display: block !important; }
116
+ .panel-content .d2h-file-side-diff {
117
+ width: 100% !important;
118
+ display: block !important;
119
+ border: none !important;
120
+ float: none !important;
121
+ }
122
+
123
+ /* File header */
124
+ .panel-content .d2h-file-header {
125
+ background: var(--gh-surface) !important;
126
+ border-bottom: 1px solid var(--gh-border) !important;
127
+ border-radius: 0 !important;
128
+ padding: 6px 14px !important;
129
+ }
130
+
131
+ .panel-content .d2h-file-name {
132
+ color: var(--gh-link) !important;
133
+ font-size: 12px;
134
+ font-family: var(--mono);
135
+ }
136
+
137
+ /* Added / removed tags (e.g. "+3 -1") */
138
+ .panel-content .d2h-tag {
139
+ font-size: 11px !important;
140
+ border-radius: 2em !important;
141
+ }
142
+ .panel-content .d2h-added-tag {
143
+ background: var(--gh-add-bg) !important;
144
+ color: var(--gh-add-num-text) !important;
145
+ border: 1px solid var(--gh-add-num-border) !important;
146
+ }
147
+ .panel-content .d2h-deleted-tag {
148
+ background: var(--gh-del-bg) !important;
149
+ color: var(--gh-del-num-text) !important;
150
+ border: 1px solid rgba(255,129,130,.6) !important;
151
+ }
152
+
153
+ /* Code table */
154
+ .panel-content .d2h-code-wrapper { border: none; }
155
+
156
+ .panel-content table.d2h-diff-table {
157
+ width: 100%;
158
+ font-size: 12px;
159
+ font-family: var(--mono);
160
+ border-collapse: collapse;
161
+ }
162
+
163
+ /* Unchanged lines */
164
+ .panel-content td.d2h-code-line,
165
+ .panel-content td.d2h-code-side-line { background: var(--gh-canvas); }
166
+
167
+ /* Deletions */
168
+ .panel-content td.d2h-del { background: var(--gh-del-bg) !important; }
169
+ .panel-content td.d2h-del .d2h-del { background: rgba(255,129,130,.4); border-radius: 2px; }
170
+
171
+ /* Additions */
172
+ .panel-content td.d2h-ins { background: var(--gh-add-bg) !important; }
173
+ .panel-content td.d2h-ins .d2h-ins { background: rgba(171,242,188,.8); border-radius: 2px; }
174
+
175
+ /* Line number gutter */
176
+ .panel-content .d2h-code-linenumber,
177
+ .panel-content td.d2h-code-side-linenumber {
178
+ position: sticky;
179
+ left: 0;
180
+ z-index: 2;
181
+ background: var(--gh-surface) !important;
182
+ border-right: 1px solid var(--gh-border) !important;
183
+ color: var(--gh-muted) !important;
184
+ min-width: 44px;
185
+ width: 44px;
186
+ padding: 0 10px !important;
187
+ text-align: right;
188
+ user-select: none;
189
+ font-family: var(--mono);
190
+ font-size: 12px;
191
+ }
192
+
193
+ .panel-content .d2h-code-linenumber.d2h-del,
194
+ .panel-content td.d2h-code-side-linenumber.d2h-del {
195
+ background: var(--gh-del-num-bg) !important;
196
+ border-right-color: var(--gh-del-num-border) !important;
197
+ color: var(--gh-del-num-text) !important;
198
+ z-index: 2;
199
+ }
200
+
201
+ .panel-content .d2h-code-linenumber.d2h-ins,
202
+ .panel-content td.d2h-code-side-linenumber.d2h-ins {
203
+ background: var(--gh-add-num-bg) !important;
204
+ border-right-color: var(--gh-add-num-border) !important;
205
+ color: var(--gh-add-num-text) !important;
206
+ z-index: 2;
207
+ }
208
+
209
+ /* Hunk / context header */
210
+ .panel-content td.d2h-info,
211
+ .panel-content .d2h-info {
212
+ background: var(--gh-hunk-bg) !important;
213
+ color: var(--gh-hunk-text) !important;
214
+ border-top: 1px solid var(--gh-hunk-border) !important;
215
+ border-bottom: 1px solid var(--gh-hunk-border) !important;
216
+ font-family: var(--mono);
217
+ font-size: 12px;
218
+ }
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'launchy'
5
+
6
+ module Diffstitch
7
+ class CLI
8
+ OUTPUT_BASE = File.join('.diffstitch', 'output')
9
+
10
+ def self.start(argv = ARGV)
11
+ new.run(argv)
12
+ end
13
+
14
+ def run(argv)
15
+ options = parse_options!(argv)
16
+ validate_repo!
17
+ validate_args!(argv)
18
+
19
+ base = argv[0]
20
+ branches = argv[1..]
21
+
22
+ verify_refs!(base, branches)
23
+
24
+ title = options[:title] || "diffstitch: #{branches.join(' | ')} vs #{base}"
25
+ output_dir = options[:output] || derived_output(base, branches)
26
+ diffs = collect_diffs(base, branches)
27
+
28
+ Generator.new(base: base, branches: branches, diffs: diffs, title: title).write(output_dir)
29
+
30
+ index = File.expand_path(File.join(output_dir, 'index.html'))
31
+ puts "Generated: #{index}"
32
+ open_in_browser(index) if options[:open]
33
+ end
34
+
35
+ private
36
+
37
+ def parse_options!(argv)
38
+ options = { output: nil, open: false }
39
+
40
+ OptionParser.new do |opts|
41
+ opts.banner = <<~BANNER
42
+ diffstitch — compare multiple git branches against a base in a split HTML view
43
+
44
+ Usage: diffstitch <base> <branch1> [branch2 ...] [options]
45
+ BANNER
46
+
47
+ opts.on('-o', '--output DIR', "Output directory (default: #{OUTPUT_BASE}/<base>_vs_<branches>)") do |v|
48
+ options[:output] = v
49
+ end
50
+ opts.on('--open', 'Open result in browser after generating') { options[:open] = true }
51
+ opts.on('--title TITLE', 'Custom page title') { |v| options[:title] = v }
52
+ opts.on_tail('-v', '--version', 'Show version') do
53
+ puts VERSION
54
+ exit
55
+ end
56
+ opts.on_tail('-h', '--help', 'Show this help') do
57
+ puts opts
58
+ exit
59
+ end
60
+ end.parse!(argv)
61
+
62
+ options
63
+ end
64
+
65
+ def validate_repo!
66
+ abort 'Error: not inside a git repository.' unless Git.in_repo?
67
+ end
68
+
69
+ def validate_args!(argv)
70
+ return if argv.length >= 2
71
+
72
+ abort "Error: provide a base branch and at least one comparison branch.\n" \
73
+ 'Usage: diffstitch <base> <branch1> [branch2 ...]'
74
+ end
75
+
76
+ def verify_refs!(base, branches)
77
+ [base, *branches].each { |ref| Git.verify_ref!(ref) }
78
+ rescue Git::Error => e
79
+ abort "Error: #{e.message}"
80
+ end
81
+
82
+ def collect_diffs(base, branches)
83
+ branches.to_h { |branch| [branch, Git.diff(base, branch)] }
84
+ rescue Git::Error => e
85
+ abort "Error: #{e.message}"
86
+ end
87
+
88
+ def derived_output(base, branches)
89
+ sanitize = ->(b) { b.gsub(%r{[/\\]}, '-') }
90
+ name = "#{sanitize.call(base)}_vs_#{branches.map(&sanitize).join('_')}"
91
+ File.join(OUTPUT_BASE, name)
92
+ end
93
+
94
+ def open_in_browser(path)
95
+ Launchy.open(path)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'erb'
6
+
7
+ module Diffstitch
8
+ class Generator
9
+ ASSETS_DIR = File.expand_path('assets', __dir__)
10
+
11
+ def initialize(base:, branches:, diffs:, title:)
12
+ @base = base
13
+ @branches = branches
14
+ @diffs = diffs
15
+ @title = title
16
+ end
17
+
18
+ def write(output_dir)
19
+ FileUtils.mkdir_p(output_dir)
20
+ write_data_js(output_dir)
21
+ %w[bootstrap.min.css styles.css app.js].each { |f| FileUtils.cp(asset(f), output_dir) }
22
+ write_html(output_dir)
23
+ end
24
+
25
+ private
26
+
27
+ def asset(name)
28
+ File.join(ASSETS_DIR, name)
29
+ end
30
+
31
+ def write_data_js(dir)
32
+ payload = JSON.generate({ base: @base, title: @title, branches: @diffs })
33
+ File.write(File.join(dir, 'data.js'), "const DIFF_DATA = #{payload};\n")
34
+ end
35
+
36
+ def write_html(dir)
37
+ title = @title
38
+ template = ERB.new(File.read(asset('index.html.erb')), trim_mode: '-')
39
+ File.write(File.join(dir, 'index.html'), template.result(binding))
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Diffstitch
6
+ module Git
7
+ class Error < StandardError; end
8
+
9
+ def self.in_repo?
10
+ _, _, status = Open3.capture3('git', 'rev-parse', '--git-dir')
11
+ status.success?
12
+ end
13
+
14
+ def self.verify_ref!(ref)
15
+ _, err, status = Open3.capture3('git', 'rev-parse', '--verify', "#{ref}^{commit}")
16
+ raise Error, "'#{ref}' is not a valid branch or commit.\n#{err.strip}" unless status.success?
17
+ end
18
+
19
+ def self.diff(base, branch)
20
+ out, err, status = Open3.capture3('git', 'diff', "#{base}..#{branch}", '--no-color')
21
+ raise Error, "git diff #{base}..#{branch} failed:\n#{err.strip}" unless status.success?
22
+
23
+ out
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Diffstitch
4
+ VERSION = '1.0.3'
5
+ end
data/lib/diffstitch.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'diffstitch/version'
4
+ require_relative 'diffstitch/git'
5
+ require_relative 'diffstitch/generator'
6
+ require_relative 'diffstitch/cli'
7
+
8
+ module Diffstitch
9
+ end
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: diffstitch
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Steven Roomberg
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: launchy
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.13'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop-rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ description: A CLI tool that generates a side-by-side HTML report comparing multiple
84
+ git branches against a common base branch.
85
+ email:
86
+ - stevenroomberg@gmail.com
87
+ executables:
88
+ - diffstitch
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - CHANGELOG.md
93
+ - Gemfile
94
+ - LICENSE
95
+ - bin/diffstitch
96
+ - diffstitch.gemspec
97
+ - lib/diffstitch.rb
98
+ - lib/diffstitch/assets/app.js
99
+ - lib/diffstitch/assets/bootstrap.min.css
100
+ - lib/diffstitch/assets/index.html.erb
101
+ - lib/diffstitch/assets/styles.css
102
+ - lib/diffstitch/cli.rb
103
+ - lib/diffstitch/generator.rb
104
+ - lib/diffstitch/git.rb
105
+ - lib/diffstitch/version.rb
106
+ homepage: https://github.com/sroomberg/diffstitch
107
+ licenses:
108
+ - MIT
109
+ metadata:
110
+ homepage_uri: https://github.com/sroomberg/diffstitch
111
+ source_code_uri: https://github.com/sroomberg/diffstitch
112
+ changelog_uri: https://github.com/sroomberg/diffstitch/blob/main/CHANGELOG.md
113
+ post_install_message:
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: 2.7.0
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubygems_version: 3.5.22
129
+ signing_key:
130
+ specification_version: 4
131
+ summary: Compare multiple git branches against a base in a split HTML view
132
+ test_files: []