changelog-builder 1.0.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.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Changelogger
4
+ # Changelog generator.
5
+ class ChangelogGenerator
6
+ class << self
7
+ # +Changelogger::ChangelogGenerator.build_sections+ -> String
8
+ #
9
+ # Build sections from versioned commits.
10
+ #
11
+ # @param [Array<(Integer, Changelogger::Commit, String)>] versioned
12
+ # @return [String]
13
+ def build_sections(versioned)
14
+ versioned.map do |(_i, c, v)|
15
+ lines = []
16
+ lines << "## [#{v}] - #{c.date}"
17
+ lines << ""
18
+ lines << "- #{c.subject} (#{c.short})"
19
+ c.body.split("\n").each { |b| lines << " #{b}" } unless c.body.nil? || c.body.empty?
20
+ lines << ""
21
+ lines.join("\n")
22
+ end.join("\n")
23
+ end
24
+
25
+ # +Changelogger::ChangelogGenerator.render+ -> String
26
+ #
27
+ # Render the changelog content.
28
+ #
29
+ # @param [Array<Changelogger::Commit>] commits
30
+ # @param [Array<String>] anchor_shas
31
+ # @param [Integer] major
32
+ # @param [Integer] minor_start
33
+ # @param [Integer] base_patch
34
+ # @return [String]
35
+ def render(commits, anchor_shas, major: 0, minor_start: 1, base_patch: 10)
36
+ sha_to_idx = commits.map(&:sha)
37
+ short_to_idx = commits.map(&:short)
38
+
39
+ anchor_indices = anchor_shas.filter_map do |sha|
40
+ full_idx = sha_to_idx.index(sha)
41
+ full_idx || short_to_idx.index(sha[0, 7])
42
+ end
43
+ raise "Need at least 2 valid commits selected" if anchor_indices.size < 2
44
+
45
+ versioned = Versioner.assign(commits, anchor_indices, major: major, minor_start: minor_start,
46
+ base_patch: base_patch)
47
+ header = "## [Unreleased]\n\n"
48
+ sections = build_sections(versioned)
49
+ [header, sections].join("\n")
50
+ end
51
+
52
+ # +Changelogger::ChangelogGenerator.generate+ -> String
53
+ #
54
+ # Generate the changelog file.
55
+ #
56
+ # @param [Array<Changelogger::Commit>] commits
57
+ # @param [Array<String>] anchor_shas
58
+ # @param [String] path
59
+ # @param [Integer] major
60
+ # @param [Integer] minor_start
61
+ # @param [Integer] base_patch
62
+ # @return [String] path
63
+ def generate(commits, anchor_shas, path: "CHANGELOG.md", major: 0, minor_start: 1, base_patch: 10)
64
+ content = render(commits, anchor_shas, major: major, minor_start: minor_start, base_patch: base_patch)
65
+ File.write(path, content)
66
+ path
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+
7
+ require_relative "version"
8
+ require_relative "git"
9
+ require_relative "changelog_generator"
10
+
11
+ module Changelogger
12
+ # +Changelogger::CLI+ provides both TUI and non-interactive CLI entrypoints.
13
+ class CLI
14
+ # +Changelogger::CLI.start+ -> Integer
15
+ #
16
+ # Parses CLI arguments and runs either interactive TUI or headless generation.
17
+ # Returns an exit code (0 on success).
18
+ #
19
+ # @param [Array<String>] argv command line arguments (default: ARGV)
20
+ # @return [Integer] exit status code
21
+ def self.start(argv = ARGV)
22
+ new.start(argv)
23
+ end
24
+
25
+ # +Changelogger::CLI#start+ -> Integer
26
+ #
27
+ # @param [Array<String>] argv
28
+ # @return [Integer] exit code
29
+ def start(argv)
30
+ mode = :tui
31
+ anchors = []
32
+ output = "CHANGELOG.md"
33
+ dry_run = false
34
+ major = 0
35
+ minor_start = 1
36
+ base_patch = 10
37
+
38
+ parser = OptionParser.new do |o|
39
+ o.banner = "Usage: changelogger [options] [REPO]"
40
+ o.separator ""
41
+ o.separator "REPO can be:"
42
+ o.separator " - local path (/path/to/repo)"
43
+ o.separator " - GitHub slug (owner/repo)"
44
+ o.separator " - git URL (https://... or git@...)"
45
+ o.separator ""
46
+ o.on("-g", "--generate", "Non-interactive: generate CHANGELOG from anchors") { mode = :generate }
47
+ o.on("-a", "--anchors x,y,z", Array, "Anchors (SHA/tag/branch), 2+ required in chronological order") do |v|
48
+ anchors = v || []
49
+ end
50
+ o.on("-o", "--output PATH", "Output file (default: CHANGELOG.md)") { |v| output = v }
51
+ o.on("--major N", Integer, "Major version (default: 0)") { |v| major = v }
52
+ o.on("--minor-start N", Integer, "Minor start index (default: 1)") { |v| minor_start = v }
53
+ o.on("--base-patch N", Integer, "Patch spacing base (default: 10)") { |v| base_patch = v }
54
+ o.on("--dry-run", "Print to stdout (do not write file)") { dry_run = true }
55
+ o.on("--tui", "Force interactive TUI (default if no --generate)") { mode = :tui }
56
+ o.on("-v", "--version", "Print version") do
57
+ puts Changelogger::VERSION
58
+ return 0
59
+ end
60
+ o.on("-h", "--help", "Show help") do
61
+ puts o
62
+ return 0
63
+ end
64
+ end
65
+
66
+ repo_spec = nil
67
+ begin
68
+ parser.order!(argv) { |arg| repo_spec ||= arg }
69
+ rescue OptionParser::ParseError => e
70
+ warn e.message
71
+ warn parser
72
+ return 2
73
+ end
74
+
75
+ with_repo(repo_spec) do
76
+ case mode
77
+ when :generate
78
+ return run_generate(anchors, output, dry_run, major, minor_start, base_patch)
79
+ else
80
+ return run_tui(output, major, minor_start, base_patch)
81
+ end
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ # +Changelogger::CLI#run_tui+ -> Integer
88
+ #
89
+ # Launches TUI and writes the CHANGELOG if the user selected 2+ anchors.
90
+ # @param [String] output output path
91
+ # @param [Integer] major
92
+ # @param [Integer] minor_start
93
+ # @param [Integer] base_patch
94
+ # @return [Integer] exit code
95
+ def run_tui(output, major, minor_start, base_patch)
96
+ require_relative "tui"
97
+ selected = Changelogger::TUI.run
98
+ return 0 if selected.nil? # cancelled
99
+
100
+ if selected.size >= 2
101
+ commits = Changelogger::Git.commits
102
+ path = Changelogger::ChangelogGenerator.generate(
103
+ commits,
104
+ selected,
105
+ path: output,
106
+ major: major,
107
+ minor_start: minor_start,
108
+ base_patch: base_patch
109
+ )
110
+ puts "Wrote #{path} ✅"
111
+ 0
112
+ else
113
+ puts "No CHANGELOG generated (need at least 2 commits)."
114
+ 1
115
+ end
116
+ end
117
+
118
+ # +Changelogger::CLI#run_generate+ -> Integer
119
+ #
120
+ # Headless mode: resolves anchors, renders or writes the CHANGELOG.
121
+ # @param [Array<String>] anchor_tokens tokens (sha/tag/branch)
122
+ # @param [String] output path
123
+ # @param [Boolean] dry_run if true, print to stdout
124
+ # @param [Integer] major
125
+ # @param [Integer] minor_start
126
+ # @param [Integer] base_patch
127
+ # @return [Integer] exit code
128
+ def run_generate(anchor_tokens, output, dry_run, major, minor_start, base_patch)
129
+ if anchor_tokens.size < 2
130
+ warn "Error: --generate requires at least 2 --anchors (SHA/tag/branch)."
131
+ return 2
132
+ end
133
+
134
+ resolved = anchor_tokens.filter_map { |t| resolve_commit(t) }
135
+ if resolved.size < 2
136
+ warn "Error: could not resolve at least two anchors: #{anchor_tokens.inspect}"
137
+ return 2
138
+ end
139
+
140
+ commits = Changelogger::Git.commits
141
+ if dry_run
142
+ puts Changelogger::ChangelogGenerator.render(
143
+ commits,
144
+ resolved,
145
+ major: major,
146
+ minor_start: minor_start,
147
+ base_patch: base_patch
148
+ )
149
+ else
150
+ path = Changelogger::ChangelogGenerator.generate(
151
+ commits,
152
+ resolved,
153
+ path: output,
154
+ major: major,
155
+ minor_start: minor_start,
156
+ base_patch: base_patch
157
+ )
158
+ puts "Wrote #{path} ✅"
159
+ end
160
+ 0
161
+ end
162
+
163
+ # ---------- repo resolution ----------
164
+
165
+ # +Changelogger::CLI#with_repo+ -> Integer
166
+ #
167
+ # Changes directory into a target repo (path/slug/url) for the duration of the block.
168
+ # Clones remotes into a temporary directory and cleans it up afterwards.
169
+ #
170
+ # @param [String, nil] repo_spec path, GitHub slug (owner/repo), or git URL
171
+ # @yield [] block to execute inside the repo
172
+ # @return [Integer] 0
173
+ def with_repo(repo_spec)
174
+ orig_dir = Dir.pwd
175
+ tmp_dir = nil
176
+
177
+ if repo_spec && !repo_spec.empty?
178
+ if File.directory?(repo_spec)
179
+ Dir.chdir(File.expand_path(repo_spec))
180
+ unless inside_git_repo?
181
+ warn "#{repo_spec.inspect} is not a git repository. Continuing, but output may be empty."
182
+ end
183
+ else
184
+ url = looks_like_url?(repo_spec) ? repo_spec : github_slug_to_url(repo_spec)
185
+ if url
186
+ tmp_dir = Dir.mktmpdir("changelogger-")
187
+ clone_ok = system("git", "clone", "--no-checkout", "--filter=blob:none", "--depth=1000", url, tmp_dir,
188
+ out: File::NULL, err: File::NULL)
189
+ clone_ok ||= system("git", "clone", url, tmp_dir)
190
+ if clone_ok
191
+ Dir.chdir(tmp_dir)
192
+ else
193
+ warn "Failed to clone #{url}. Running in current directory."
194
+ end
195
+ else
196
+ warn "Unrecognized repo argument: #{repo_spec.inspect}. Expected a directory, GitHub slug (owner/repo), or git URL."
197
+ end
198
+ end
199
+ end
200
+
201
+ yield
202
+ 0
203
+ ensure
204
+ begin
205
+ Dir.chdir(orig_dir)
206
+ rescue StandardError
207
+ nil
208
+ end
209
+ FileUtils.remove_entry(tmp_dir) if tmp_dir && File.directory?(tmp_dir)
210
+ end
211
+
212
+ # +Changelogger::CLI#inside_git_repo?+ -> Bool
213
+ # @return [Bool] true if inside a git work tree
214
+ def inside_git_repo?
215
+ system("git", "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
216
+ end
217
+
218
+ # +Changelogger::CLI#looks_like_url?+ -> Bool
219
+ # @param [String] s
220
+ # @return [Bool]
221
+ def looks_like_url?(s)
222
+ s =~ %r{\Ahttps?://} || s.start_with?("git@")
223
+ end
224
+
225
+ # +Changelogger::CLI#github_slug_to_url+ -> String, nil
226
+ # Converts "owner/repo" or "github.com/owner/repo" into an https URL.
227
+ # @param [String] s
228
+ # @return [String, nil]
229
+ def github_slug_to_url(s)
230
+ if s =~ %r{\Ahttps?://(?:www\.)?github\.com/([\w.-]+/[\w.-]+)(?:\.git)?\z}i
231
+ "https://github.com/#{::Regexp.last_match(1)}.git"
232
+ elsif s =~ %r{\A(?:github\.com/)?([\w.-]+/[\w.-]+)(?:\.git)?\z}i
233
+ "https://github.com/#{::Regexp.last_match(1)}.git"
234
+ end
235
+ end
236
+
237
+ # +Changelogger::CLI#resolve_commit+ -> String, nil
238
+ #
239
+ # Resolves a token (sha/tag/branch) to a 40-char commit SHA.
240
+ # @param [String] token
241
+ # @return [String, nil]
242
+ def resolve_commit(token)
243
+ full = `git rev-parse -q --verify #{token}^{commit} 2>/dev/null`.strip
244
+ $CHILD_STATUS.success? && full.match?(/\A[0-9a-f]{40}\z/i) ? full : nil
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Changelogger
4
+ # +Changelogger::Commit+ is a plain struct representing a git commit.
5
+ #
6
+ # @!attribute [rw] sha
7
+ # @return [String] Full 40-char SHA
8
+ # @!attribute [rw] short
9
+ # @return [String] Abbreviated SHA
10
+ # @!attribute [rw] date
11
+ # @return [String] Commit date (YYYY-MM-DD)
12
+ # @!attribute [rw] subject
13
+ # @return [String] First line message
14
+ # @!attribute [rw] body
15
+ # @return [String] Remaining message body (could be empty)
16
+ Commit = Struct.new(:sha, :short, :date, :subject, :body, keyword_init: true)
17
+
18
+ # +Changelogger::Git+ wraps read-only git queries used by this gem.
19
+ class Git
20
+ # Separator used for pretty-format parsing
21
+ SEP = "\x01"
22
+
23
+ # +Changelogger::Git.commits+ -> Array<Changelogger::Commit>
24
+ #
25
+ # Returns repository commits in chronological order (oldest-first).
26
+ # Uses: git log --date=short --reverse --pretty=format:'...'
27
+ #
28
+ # @return [Array<Commit>]
29
+ def self.commits # rubocop:disable Metrics/MethodLength]
30
+ format = "%H#{SEP}%h#{SEP}%ad#{SEP}%s#{SEP}%b"
31
+ cmd = "git log --date=short --reverse --pretty=format:'#{format}'"
32
+ out = `#{cmd}`
33
+ out.split("\n").map do |line|
34
+ sha, short, date, subject, body = line.split(SEP, 5)
35
+ Commit.new(
36
+ sha: sha,
37
+ short: short,
38
+ date: date,
39
+ subject: (subject || "").strip,
40
+ body: (body || "").strip
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Changelogger
4
+ # +Changelogger::Header+ draws the top header (title/version) of the TUI.
5
+ class Header
6
+ # +Changelogger::Header.new+ -> Changelogger::Header
7
+ #
8
+ # Draws a single header line with the gem name and version.
9
+ #
10
+ # @param [Integer] height window height
11
+ # @param [Integer] width window width
12
+ # @param [Integer] top top position
13
+ # @param [Integer] left left position
14
+ # @return [Header]
15
+ def initialize(height: 0, width: Curses.cols, top: 0, left: 0)
16
+ @height = height
17
+ @width = width
18
+ @top = top
19
+ @left = left
20
+ header_win
21
+ line
22
+ end
23
+
24
+ private
25
+
26
+ # +Changelogger::Header.header_win+ -> void
27
+ # @private
28
+ # Initializes the header frame.
29
+ # @return [void]
30
+ def header_win
31
+ @header_win = Curses::Window.new(@height, @width, @top, @left)
32
+ @header_win.box(" ", " ", " ")
33
+ end
34
+
35
+ # +Changelogger::Header.line+ -> void
36
+ # @private
37
+ # Renders the header text.
38
+ # @return [void]
39
+ def line
40
+ line = @header_win.subwin(@height, @width, @top, @left)
41
+ line.addstr(" Changelogger #{Changelogger::VERSION} ".center(@width, "="))
42
+ line.refresh
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: false
2
+
3
+ require "curses"
4
+ require_relative "header"
5
+ require_relative "branches_window"
6
+ require_relative "git"
7
+ require_relative "versioner"
8
+ require_relative "changelog_generator"
9
+ require_relative "preview_window"
10
+
11
+ Curses.init_screen
12
+ Curses.cbreak
13
+ Curses.curs_set(0)
14
+ Curses.noecho
15
+
16
+ Changelogger::Header.new
17
+
18
+ win = Changelogger::BranchWindow.new
19
+ selected = win.select_commits # closes screen on exit
20
+
21
+ if selected.nil?
22
+ # ESC/q
23
+ elsif selected.size >= 2
24
+ commits = Changelogger::Git.commits
25
+ path = Changelogger::ChangelogGenerator.generate(commits, selected, path: "CHANGELOG.md")
26
+ puts "Wrote #{path} ✅"
27
+ else
28
+ puts "No CHANGELOG generated (need at least 2 commits)."
29
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "curses"
4
+
5
+ module Changelogger
6
+ # +Changelogger::PreviewWindow+ shows scrollable text in a framed window.
7
+ class PreviewWindow
8
+ # +Changelogger::PreviewWindow.new+ -> PreviewWindow
9
+ #
10
+ # @param [String] title window title
11
+ # @param [String] content initial text
12
+ # @param [Integer] top top row position
13
+ # @param [Integer] left left column position
14
+ # @param [Integer, nil] height window height or computed from screen
15
+ # @param [Integer, nil] width window width or computed from screen
16
+ def initialize(title: "Preview", content: "", top: 1, left: 0, height: nil, width: nil)
17
+ @title = title
18
+ screen_h = Curses.lines
19
+ screen_w = Curses.cols
20
+
21
+ @height = height || [screen_h - top, 3].max
22
+ @width = width || [screen_w - left, 10].max
23
+ @top = top
24
+ @left = left
25
+
26
+ @sub_height = @height - 2
27
+ @sub_width = @width - 2
28
+ @sub_top = @top + 1
29
+ @sub_left = @left + 1
30
+
31
+ @offset = 0
32
+ @lines = (content || "").split("\n")
33
+
34
+ build_windows
35
+ redraw
36
+ end
37
+
38
+ # +Changelogger::PreviewWindow#update_content+ -> void
39
+ #
40
+ # Replace content and reset scroll to top.
41
+ # @param [String] text new content
42
+ # @return [void]
43
+ def update_content(text)
44
+ @lines = (text || "").split("\n")
45
+ @offset = 0
46
+ redraw
47
+ end
48
+
49
+ # +Changelogger::PreviewWindow#run+ -> void
50
+ #
51
+ # Enters the input loop. Returns when user closes the preview.
52
+ # @return [void]
53
+ def run
54
+ loop do
55
+ case @sub.getch
56
+ when Curses::Key::UP, "k"
57
+ @offset = [@offset - 1, 0].max
58
+ redraw
59
+ when Curses::Key::DOWN, "j"
60
+ max_off = [@lines.length - @sub_height, 0].max
61
+ @offset = [@offset + 1, max_off].min
62
+ redraw
63
+ when Curses::Key::PPAGE
64
+ @offset = [@offset - @sub_height, 0].max
65
+ redraw
66
+ when Curses::Key::NPAGE
67
+ max_off = [@lines.length - @sub_height, 0].max
68
+ @offset = [@offset + @sub_height, max_off].min
69
+ redraw
70
+ when "g"
71
+ @offset = 0
72
+ redraw
73
+ when "G"
74
+ @offset = [@lines.length - @sub_height, 0].max
75
+ redraw
76
+ when "q", 27
77
+ break
78
+ end
79
+ end
80
+ ensure
81
+ destroy
82
+ end
83
+
84
+ private
85
+
86
+ # @!visibility private
87
+
88
+ def build_windows
89
+ @frame = Curses::Window.new(@height, @width, @top, @left)
90
+ @frame.box
91
+ draw_title
92
+ @frame.refresh
93
+
94
+ @sub = @frame.subwin(@sub_height, @sub_width, @sub_top, @sub_left)
95
+ @sub.keypad(true)
96
+ @sub.scrollok(false)
97
+ end
98
+
99
+ def draw_title
100
+ title = " #{@title} "
101
+ bar = title.center(@width - 2, "─")
102
+ @frame.setpos(0, 1)
103
+ @frame.addstr(bar[0, @width - 2])
104
+ end
105
+
106
+ def redraw
107
+ @sub.erase
108
+
109
+ visible = @lines[@offset, @sub_height] || []
110
+ visible.each_with_index do |line, i|
111
+ @sub.setpos(i, 0)
112
+ @sub.addstr(line.ljust(@sub_width, " ")[0, @sub_width])
113
+ end
114
+
115
+ (visible.length...@sub_height).each do |i|
116
+ @sub.setpos(i, 0)
117
+ @sub.addstr(" " * @sub_width)
118
+ end
119
+
120
+ @sub.refresh
121
+ end
122
+
123
+ def destroy
124
+ @sub&.close
125
+ @frame&.close
126
+ rescue StandardError
127
+ # ignore
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Changelogger
4
+ # +Changelogger::RepoInfo+ holds metadata about the current repository.
5
+ #
6
+ # @!attribute [rw] name
7
+ # @return [String] repo name (directory name)
8
+ # @!attribute [rw] path
9
+ # @return [String] absolute repo root path
10
+ # @!attribute [rw] branch
11
+ # @return [String] HEAD branch or "(detached)"
12
+ # @!attribute [rw] head_short
13
+ # @return [String] abbreviated HEAD sha
14
+ # @!attribute [rw] remote
15
+ # @return [String] remote.origin.url (may be empty)
16
+ # @!attribute [rw] remote_slug
17
+ # @return [String, nil] "owner/repo" for GitHub remotes, otherwise nil
18
+ # @!attribute [rw] dirty
19
+ # @return [Boolean] true if there are uncommitted changes
20
+ RepoInfo = Struct.new(:name, :path, :branch, :head_short, :remote, :remote_slug, :dirty, keyword_init: true)
21
+
22
+ # +Changelogger::Repo+ reads basic repository info for display.
23
+ class Repo
24
+ class << self
25
+ # +Changelogger::Repo.info+ -> Changelogger::RepoInfo
26
+ #
27
+ # Reads repo root, branch, HEAD short sha, origin url, and dirty flag.
28
+ # @return [RepoInfo]
29
+ def info
30
+ path = cmd("git rev-parse --show-toplevel").strip
31
+ name = path.empty? ? File.basename(Dir.pwd) : File.basename(path)
32
+ branch = cmd("git rev-parse --abbrev-ref HEAD").strip
33
+ branch = "(detached)" if branch.empty? || branch == "HEAD"
34
+ head_short = cmd("git rev-parse --short HEAD").strip
35
+ remote = cmd("git config --get remote.origin.url").strip
36
+ dirty = !cmd("git status --porcelain").strip.empty?
37
+
38
+ RepoInfo.new(
39
+ name: name,
40
+ path: path.empty? ? Dir.pwd : path,
41
+ branch: branch,
42
+ head_short: head_short,
43
+ remote: remote,
44
+ remote_slug: to_slug(remote),
45
+ dirty: dirty
46
+ )
47
+ end
48
+
49
+ private
50
+
51
+ # +Changelogger::Repo.cmd+ -> String
52
+ # @private
53
+ # Runs a shell command and returns its stdout (or empty string on error).
54
+ # @param [String] s shell command
55
+ # @return [String]
56
+ def cmd(s)
57
+ `#{s} 2>/dev/null`
58
+ rescue StandardError
59
+ ""
60
+ end
61
+
62
+ # +Changelogger::Repo.to_slug+ -> String, nil
63
+ # @private
64
+ # Extracts owner/repo from GitHub remotes or returns nil for others.
65
+ # @param [String] url git remote URL
66
+ # @return [String, nil]
67
+ def to_slug(url)
68
+ return nil if url.to_s.empty?
69
+
70
+ if url =~ %r{\Agit@github\.com:([\w.-]+/[\w.-]+)(?:\.git)?\z}i
71
+ Regexp.last_match(1)
72
+ elsif url =~ %r{\Ahttps?://(?:www\.)?github\.com/([\w.-]+/[\w.-]+)(?:\.git)?\z}i
73
+ Regexp.last_match(1)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "curses"
4
+ require_relative "header"
5
+ require_relative "branches_window"
6
+
7
+ module Changelogger
8
+ # +Changelogger::TUI+ wraps curses lifecycle and runs the side-by-side UI.
9
+ class TUI
10
+ # +Changelogger::TUI.run+ -> Array<String>, nil
11
+ #
12
+ # Starts curses, draws the header and graph/preview panes, and returns the
13
+ # selected anchor SHAs when the user presses Enter.
14
+ #
15
+ # @return [Array<String>, nil] array of SHAs (2+) or nil if cancelled (q/ESC)
16
+ def self.run
17
+ Curses.init_screen
18
+ Curses.cbreak
19
+ Curses.noecho
20
+ Curses.curs_set(0)
21
+ begin
22
+ begin
23
+ Curses.start_color
24
+ Curses.use_default_colors if Curses.respond_to?(:use_default_colors)
25
+ rescue StandardError
26
+ end
27
+ Changelogger::Header.new
28
+ win = Changelogger::BranchWindow.new
29
+ win.select_commits
30
+ ensure
31
+ begin
32
+ Curses.close_screen
33
+ rescue StandardError
34
+ nil
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Changelogger
4
+ VERSION = "1.0.0"
5
+ end