gitflash 0.4.0 → 0.5.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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Resets the current branch to a commit given as an argument or picked from a menu.
6
+ # Default mode is mixed; --soft and --hard are supported. Only --hard asks for confirmation.
7
+ # The result includes the previous HEAD, so the reset can be reverted.
8
+ class Reset < Base
9
+ HARD_RESET_WARNING = 'You are about to reset your branch and lose all your current changes'
10
+
11
+ def call(ref = nil)
12
+ mode = reset_mode
13
+
14
+ if ref.nil?
15
+ require_interactive!('Pass the commit to reset to: gitflash reset COMMIT')
16
+ commits = repo.commits
17
+ return report_single_commit if commits.size < 2
18
+
19
+ ref = ui.select('Select a commit to reset to', commits.to_h { |c| [c.label, c.sha] })
20
+ end
21
+
22
+ reset(ref, mode)
23
+ end
24
+
25
+ private
26
+
27
+ def reset_mode
28
+ if options[:soft] && options[:hard]
29
+ usage_error!('invalid_options', 'Use either --soft or --hard, not both')
30
+ end
31
+
32
+ return 'hard' if options[:hard]
33
+
34
+ options[:soft] ? 'soft' : 'mixed'
35
+ end
36
+
37
+ def reset(ref, mode)
38
+ commit = repo.resolve_commit(ref)
39
+ usage_error!('unknown_commit', "Unknown commit '#{ref}'") if commit.nil?
40
+
41
+ plan = { commit: commit, mode: mode }
42
+ text = "reset to #{commit[0, 7]} (#{mode})"
43
+ return planned(plan, "Would #{text}") if ui.dry_run?
44
+ return cancelled(plan) unless confirmed?(plan)
45
+
46
+ execute(plan, text)
47
+ end
48
+
49
+ def execute(plan, text)
50
+ previous = repo.resolve_commit('HEAD')
51
+ result = repo.reset(plan[:commit], mode: plan[:mode])
52
+ git_error!('reset', result) unless result.success?
53
+
54
+ ui.report(status: 'done', plan: plan, result: plan.merge(previous_commit: previous),
55
+ text: text.capitalize)
56
+ end
57
+
58
+ def confirmed?(plan)
59
+ plan[:mode] != 'hard' || ui.confirm?(HARD_RESET_WARNING, plan: plan)
60
+ end
61
+
62
+ def report_single_commit
63
+ ui.report(status: 'noop', result: nil, text: 'You only have one commit!')
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Gitflash
6
+ # A commit as reported by `git log`
7
+ Commit = Data.define(:sha, :subject, :author, :committed_at) do
8
+ # Parses one line produced with Repo::COMMIT_FORMAT
9
+ def self.parse(line)
10
+ sha, subject, author, date = line.split(Repo::SEPARATOR, 4)
11
+ new(sha: sha, subject: subject.to_s, author: author, committed_at: Time.iso8601(date))
12
+ end
13
+
14
+ def label
15
+ "#{sha} - #{subject}"
16
+ end
17
+
18
+ def to_h
19
+ super.merge(committed_at: committed_at.iso8601)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Raised when a destructive command runs without a terminal and without --yes.
5
+ # Carries the plan so the caller can review it and re-run with --yes.
6
+ class ConfirmationRequired < Error
7
+ def initialize(message = nil, plan: nil)
8
+ super(message, code: 'confirmation_required', plan: plan)
9
+ end
10
+
11
+ def exit_code
12
+ 2
13
+ end
14
+
15
+ def status
16
+ 'confirmation_required'
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Base class for errors reported to the user.
5
+ # `code` is a stable machine-readable identifier; `plan` is the planned change, if any.
6
+ class Error < StandardError
7
+ attr_reader :code, :plan
8
+
9
+ def initialize(message = nil, code: 'failed', plan: nil)
10
+ super(message)
11
+ @code = code
12
+ @plan = plan
13
+ end
14
+
15
+ def exit_code
16
+ 1
17
+ end
18
+
19
+ def status
20
+ 'error'
21
+ end
22
+ end
23
+ end
@@ -9,16 +9,18 @@ module Gitflash
9
9
  # Runs a command without a shell and returns its stdout.
10
10
  # Raises CommandError when the command exits with a non-zero status.
11
11
  def exec(*args)
12
- stdout, stderr, status = Open3.capture3(*args)
13
- raise CommandError, "#{args.join(' ')} failed: #{stderr.strip}" unless status.success?
12
+ stdout, stderr, success = capture(*args)
13
+ raise CommandError, "#{args.join(' ')} failed: #{stderr.strip}" unless success
14
14
 
15
15
  stdout
16
- rescue SystemCallError => e
17
- raise CommandError, "#{args.join(' ')} failed: #{e.message}"
18
16
  end
19
17
 
20
- def system_exec(*)
21
- system(*)
18
+ # Runs a command without a shell and returns [stdout, stderr, success].
19
+ def capture(*args)
20
+ stdout, stderr, status = Open3.capture3(*args)
21
+ [stdout, stderr, status.success?]
22
+ rescue SystemCallError => e
23
+ raise CommandError, "#{args.join(' ')} failed: #{e.message}"
22
24
  end
23
25
  end
24
26
  end
@@ -2,6 +2,10 @@
2
2
 
3
3
  module Gitflash
4
4
  module Git
5
- class CommandError < StandardError; end
5
+ class CommandError < Gitflash::Error
6
+ def initialize(message = nil, code: 'git_failed', plan: nil)
7
+ super
8
+ end
9
+ end
6
10
  end
7
11
  end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Reads and changes the git repository in the current working directory.
5
+ # All git access goes through this class; it never prompts or prints.
6
+ class Repo
7
+ SEPARATOR = "\x1f"
8
+ BRANCH_FORMAT = %w[
9
+ refname:short objectname:short HEAD upstream:short upstream:track,nobracket
10
+ committerdate:iso-strict authorname subject
11
+ ].map { |field| "%(#{field})" }.join('%1f')
12
+ COMMIT_FORMAT = %w[%h %s %an %cI].join('%x1f')
13
+ COMMITS_LIMIT = 100
14
+ FALLBACK_DEFAULT_BRANCHES = %w[main master].freeze
15
+
16
+ def initialize(bash: Git::BashCommand)
17
+ @bash = bash
18
+ end
19
+
20
+ def ensure_work_tree!
21
+ inside = @bash.exec('git', 'rev-parse', '--is-inside-work-tree').strip == 'true'
22
+ raise Error.new('Not a git repository', code: 'not_a_repository') unless inside
23
+ rescue Git::CommandError
24
+ raise Error.new('Not a git repository', code: 'not_a_repository')
25
+ end
26
+
27
+ # Local branches. With `merged_status: true` each branch also reports whether it is
28
+ # merged into the default branch (one extra git call).
29
+ def branches(merged_status: false)
30
+ merged_names = merged_status ? merged_branch_names : nil
31
+ branch_lines.map do |line|
32
+ Branch.parse(line, default_branch: default_branch, merged_names: merged_names)
33
+ end
34
+ end
35
+
36
+ def current_branch
37
+ branches.find(&:current?)&.name
38
+ end
39
+
40
+ # The branch that origin/HEAD points to, else main or master when present locally
41
+ def default_branch
42
+ return @default_branch if defined?(@default_branch)
43
+
44
+ @default_branch = default_ref&.delete_prefix('origin/')
45
+ end
46
+
47
+ # Commits of the current branch, newest first. Empty for a branch without commits.
48
+ def commits(limit: COMMITS_LIMIT)
49
+ @bash.exec('git', 'log', "--max-count=#{limit}", "--format=#{COMMIT_FORMAT}")
50
+ .each_line(chomp: true).reject(&:empty?).map { |line| Commit.parse(line) }
51
+ rescue Git::CommandError
52
+ []
53
+ end
54
+
55
+ # Full sha of a commit reference, or nil when it does not name a commit
56
+ def resolve_commit(ref)
57
+ stdout, _stderr, success = @bash.capture('git', 'rev-parse', '--verify', '--quiet',
58
+ "#{ref}^{commit}")
59
+ success ? stdout.strip : nil
60
+ end
61
+
62
+ def checkout(branch)
63
+ run('git', 'checkout', branch, '--')
64
+ end
65
+
66
+ def delete_branch(branch, force: false)
67
+ run('git', 'branch', force ? '-D' : '-d', branch)
68
+ end
69
+
70
+ def reset(commit, mode:)
71
+ run('git', 'reset', "--#{mode}", commit, '--')
72
+ end
73
+
74
+ private
75
+
76
+ def branch_lines
77
+ @branch_lines ||= @bash.exec('git', 'for-each-ref', "--format=#{BRANCH_FORMAT}",
78
+ 'refs/heads/')
79
+ .each_line(chomp: true).reject(&:empty?)
80
+ end
81
+
82
+ # Ref used to check merge status: origin/HEAD's target, else a local main/master
83
+ def default_ref
84
+ return @default_ref if defined?(@default_ref)
85
+
86
+ @default_ref = remote_default_ref || local_default_branch
87
+ end
88
+
89
+ def remote_default_ref
90
+ ref = @bash.exec('git', 'symbolic-ref', '--quiet', '--short',
91
+ 'refs/remotes/origin/HEAD').strip
92
+ ref.empty? ? nil : ref
93
+ rescue Git::CommandError
94
+ nil
95
+ end
96
+
97
+ def local_default_branch
98
+ names = branch_lines.map { |line| line.split(SEPARATOR, 2).first }
99
+ FALLBACK_DEFAULT_BRANCHES.find { |name| names.include?(name) }
100
+ end
101
+
102
+ def merged_branch_names
103
+ return nil unless default_ref
104
+
105
+ @bash.exec('git', 'for-each-ref', "--merged=#{default_ref}", '--format=%(refname:short)',
106
+ 'refs/heads/')
107
+ .each_line(chomp: true).to_set
108
+ end
109
+
110
+ def run(*)
111
+ stdout, stderr, success = @bash.capture(*)
112
+ Result.new(success: success,
113
+ output: [stdout, stderr].map(&:strip).reject(&:empty?).join("\n"))
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Outcome of a git command that changes the repository
5
+ Result = Data.define(:success, :output) do
6
+ alias_method :success?, :success
7
+ end
8
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Gitflash
6
+ # Everything the user sees: human text or JSON, menus and confirmations.
7
+ # Menus and confirmations are only shown in a terminal and never in JSON mode.
8
+ #
9
+ # JSON output always uses the same envelope (see schema/v1.json):
10
+ # { schema, command, ok, status, dry_run, plan?, result?, error? }
11
+ class Ui
12
+ JSON_SCHEMA_VERSION = 1
13
+ OK_STATUSES = %w[done planned noop cancelled].freeze
14
+
15
+ def initialize(command:, json: false, yes: false, dry_run: false)
16
+ @command = command
17
+ @json = json
18
+ @yes = yes
19
+ @dry_run = dry_run
20
+ end
21
+
22
+ def json?
23
+ @json
24
+ end
25
+
26
+ def dry_run?
27
+ @dry_run
28
+ end
29
+
30
+ def interactive?
31
+ !json? && $stdin.tty?
32
+ end
33
+
34
+ # Reports the outcome of a command and returns its exit status.
35
+ # `status` is one of done, planned, noop, cancelled or failed.
36
+ def report(status:, text:, plan: nil, result: nil, error: nil)
37
+ if json?
38
+ document = envelope(status: status, plan: plan, result: result, error: error)
39
+ $stdout.puts JSON.generate(document)
40
+ else
41
+ $stdout.puts text
42
+ end
43
+ OK_STATUSES.include?(status) ? 0 : 1
44
+ end
45
+
46
+ def error(error)
47
+ if json?
48
+ details = { code: error.code, message: error.message, exit_code: error.exit_code }
49
+ $stdout.puts JSON.generate(envelope(status: error.status, plan: error.plan, error: details))
50
+ else
51
+ warn error.message
52
+ end
53
+ end
54
+
55
+ def select(message, choices, **)
56
+ prompt.select(message, choices, **)
57
+ end
58
+
59
+ def multi_select(message, choices, **)
60
+ prompt.multi_select(message, choices, **)
61
+ end
62
+
63
+ # True when the action may proceed: --yes was given or the user confirmed in a terminal.
64
+ # Raises ConfirmationRequired, carrying the plan, when nobody can confirm.
65
+ def confirm?(summary, plan:)
66
+ return true if @yes
67
+
68
+ message = "#{summary}\nRe-run with --yes to confirm."
69
+ raise ConfirmationRequired.new(message, plan: plan) unless interactive?
70
+
71
+ prompt.proceed_with_warning(summary) { true } == true
72
+ end
73
+
74
+ private
75
+
76
+ def envelope(status:, plan: nil, result: nil, error: nil)
77
+ {
78
+ schema: JSON_SCHEMA_VERSION,
79
+ command: @command,
80
+ ok: OK_STATUSES.include?(status),
81
+ status: status,
82
+ dry_run: dry_run?,
83
+ plan: plan,
84
+ result: result,
85
+ error: error
86
+ }.compact
87
+ end
88
+
89
+ def prompt
90
+ @prompt ||= Prompt.create
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Raised for invalid arguments or when input is needed but no terminal is available
5
+ class UsageError < Error
6
+ def initialize(message = nil, code: 'invalid_usage', plan: nil)
7
+ super
8
+ end
9
+
10
+ def exit_code
11
+ 2
12
+ end
13
+ end
14
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gitflash
4
- VERSION = '0.4.0'
4
+ VERSION = '0.5.0'
5
5
  end
data/schema/v1.json ADDED
@@ -0,0 +1,172 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/Kleanthismits/git_flash/blob/main/schema/v1.json",
4
+ "title": "gitflash --json output, schema version 1",
5
+ "description": "Every gitflash command run with --json prints exactly one object in this envelope. `ok` is true for the statuses done, planned, noop and cancelled. Fields may be added in later releases of version 1; existing fields keep their meaning.",
6
+ "type": "object",
7
+ "required": ["schema", "command", "ok", "status", "dry_run"],
8
+ "properties": {
9
+ "schema": { "const": 1 },
10
+ "command": { "enum": ["branches", "checkout", "delete", "reset"] },
11
+ "ok": { "type": "boolean" },
12
+ "status": {
13
+ "description": "done: the change was made. planned: --dry-run, nothing changed. noop: nothing to do. cancelled: the user declined. failed: git refused part of the change. confirmation_required: re-run with --yes after reviewing `plan`. error: invalid input or environment.",
14
+ "enum": ["done", "planned", "noop", "cancelled", "failed", "confirmation_required", "error"]
15
+ },
16
+ "dry_run": { "type": "boolean" },
17
+ "plan": { "type": "object", "description": "The change that will be, would be or was made." },
18
+ "result": { "type": "object", "description": "What happened, including what is needed to revert it." },
19
+ "error": { "$ref": "#/$defs/error" }
20
+ },
21
+ "allOf": [
22
+ {
23
+ "if": { "properties": { "status": { "enum": ["done", "planned", "noop", "cancelled"] } } },
24
+ "then": { "properties": { "ok": { "const": true } }, "not": { "required": ["error"] } },
25
+ "else": { "properties": { "ok": { "const": false } }, "required": ["error"] }
26
+ },
27
+ {
28
+ "if": { "properties": { "status": { "const": "confirmation_required" } } },
29
+ "then": { "required": ["plan"] }
30
+ },
31
+ {
32
+ "if": { "properties": { "command": { "const": "branches" } } },
33
+ "then": { "properties": { "result": { "$ref": "#/$defs/branches_result" } } }
34
+ },
35
+ {
36
+ "if": { "properties": { "command": { "const": "checkout" } } },
37
+ "then": {
38
+ "properties": {
39
+ "plan": { "$ref": "#/$defs/checkout_plan" },
40
+ "result": { "$ref": "#/$defs/checkout_result" }
41
+ }
42
+ }
43
+ },
44
+ {
45
+ "if": { "properties": { "command": { "const": "delete" } } },
46
+ "then": {
47
+ "properties": {
48
+ "plan": { "$ref": "#/$defs/delete_plan" },
49
+ "result": { "$ref": "#/$defs/delete_result" }
50
+ }
51
+ }
52
+ },
53
+ {
54
+ "if": { "properties": { "command": { "const": "reset" } } },
55
+ "then": {
56
+ "properties": {
57
+ "plan": { "$ref": "#/$defs/reset_plan" },
58
+ "result": { "$ref": "#/$defs/reset_result" }
59
+ }
60
+ }
61
+ }
62
+ ],
63
+ "$defs": {
64
+ "sha": { "type": "string", "pattern": "^[0-9a-f]{4,64}$" },
65
+ "error": {
66
+ "type": "object",
67
+ "required": ["code", "message", "exit_code"],
68
+ "properties": {
69
+ "code": {
70
+ "description": "Stable identifier to branch on; the message is for humans.",
71
+ "enum": [
72
+ "not_a_repository", "input_required", "invalid_usage", "invalid_options",
73
+ "unknown_branch", "unknown_commit", "protected_branch",
74
+ "confirmation_required", "git_failed", "failed"
75
+ ]
76
+ },
77
+ "message": { "type": "string" },
78
+ "exit_code": { "enum": [1, 2] }
79
+ }
80
+ },
81
+ "branch": {
82
+ "type": "object",
83
+ "required": [
84
+ "name", "sha", "current", "default", "upstream", "upstream_gone", "ahead", "behind",
85
+ "merged", "last_commit_at", "last_commit_author", "last_commit_subject"
86
+ ],
87
+ "properties": {
88
+ "name": { "type": "string" },
89
+ "sha": { "$ref": "#/$defs/sha" },
90
+ "current": { "type": "boolean" },
91
+ "default": { "type": "boolean", "description": "The branch origin/HEAD points to, else main or master." },
92
+ "upstream": { "type": ["string", "null"] },
93
+ "upstream_gone": { "type": "boolean", "description": "The upstream branch was deleted on the remote." },
94
+ "ahead": { "type": "integer", "minimum": 0 },
95
+ "behind": { "type": "integer", "minimum": 0 },
96
+ "merged": { "type": ["boolean", "null"], "description": "Merged into the default branch; null when there is no default branch." },
97
+ "last_commit_at": { "type": "string", "format": "date-time" },
98
+ "last_commit_author": { "type": "string" },
99
+ "last_commit_subject": { "type": "string" }
100
+ }
101
+ },
102
+ "branches_result": {
103
+ "type": "object",
104
+ "required": ["default_branch", "branches"],
105
+ "properties": {
106
+ "default_branch": { "type": ["string", "null"] },
107
+ "branches": { "type": "array", "items": { "$ref": "#/$defs/branch" } }
108
+ }
109
+ },
110
+ "checkout_plan": {
111
+ "type": "object",
112
+ "required": ["branch"],
113
+ "properties": { "branch": { "type": "string" } }
114
+ },
115
+ "checkout_result": {
116
+ "type": "object",
117
+ "required": ["branch", "previous_branch"],
118
+ "properties": {
119
+ "branch": { "type": ["string", "null"] },
120
+ "previous_branch": { "type": ["string", "null"], "description": "Check this out to go back; null on a detached HEAD." }
121
+ }
122
+ },
123
+ "delete_plan": {
124
+ "type": "object",
125
+ "required": ["branches", "force"],
126
+ "properties": {
127
+ "branches": { "type": "array", "items": { "type": "string" } },
128
+ "force": { "type": "boolean" }
129
+ }
130
+ },
131
+ "delete_result": {
132
+ "type": "object",
133
+ "required": ["deleted", "failed"],
134
+ "properties": {
135
+ "deleted": {
136
+ "type": "array",
137
+ "description": "Recreate a branch with: git branch NAME SHA",
138
+ "items": {
139
+ "type": "object",
140
+ "required": ["branch", "sha"],
141
+ "properties": { "branch": { "type": "string" }, "sha": { "$ref": "#/$defs/sha" } }
142
+ }
143
+ },
144
+ "failed": {
145
+ "type": "array",
146
+ "items": {
147
+ "type": "object",
148
+ "required": ["branch", "error"],
149
+ "properties": { "branch": { "type": "string" }, "error": { "type": "string" } }
150
+ }
151
+ }
152
+ }
153
+ },
154
+ "reset_plan": {
155
+ "type": "object",
156
+ "required": ["commit", "mode"],
157
+ "properties": {
158
+ "commit": { "$ref": "#/$defs/sha" },
159
+ "mode": { "enum": ["soft", "mixed", "hard"] }
160
+ }
161
+ },
162
+ "reset_result": {
163
+ "type": "object",
164
+ "required": ["commit", "mode", "previous_commit"],
165
+ "properties": {
166
+ "commit": { "$ref": "#/$defs/sha" },
167
+ "mode": { "enum": ["soft", "mixed", "hard"] },
168
+ "previous_commit": { "$ref": "#/$defs/sha", "description": "Reset to this commit to go back. Uncommitted changes lost by a hard reset cannot be restored this way." }
169
+ }
170
+ }
171
+ }
172
+ }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitflash
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kleanthis Mitsioulis
@@ -73,13 +73,26 @@ files:
73
73
  - bin/gitflash
74
74
  - command_descriptions.yml
75
75
  - lib/gitflash.rb
76
+ - lib/gitflash/branch.rb
76
77
  - lib/gitflash/cli.rb
78
+ - lib/gitflash/commands/base.rb
79
+ - lib/gitflash/commands/branches.rb
80
+ - lib/gitflash/commands/checkout.rb
81
+ - lib/gitflash/commands/delete.rb
82
+ - lib/gitflash/commands/reset.rb
83
+ - lib/gitflash/commit.rb
77
84
  - lib/gitflash/configuration.rb
85
+ - lib/gitflash/confirmation_required.rb
86
+ - lib/gitflash/error.rb
78
87
  - lib/gitflash/git/bash_command.rb
79
88
  - lib/gitflash/git/command_error.rb
80
- - lib/gitflash/git/wrapper.rb
81
89
  - lib/gitflash/prompt.rb
90
+ - lib/gitflash/repo.rb
91
+ - lib/gitflash/result.rb
92
+ - lib/gitflash/ui.rb
93
+ - lib/gitflash/usage_error.rb
82
94
  - lib/gitflash/version.rb
95
+ - schema/v1.json
83
96
  homepage: https://github.com/Kleanthismits/git_flash
84
97
  licenses:
85
98
  - MIT