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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 308e1c6f4eba1ab9251d7606e919e91108df058d0618b04e2c8f35aac8370ad0
4
- data.tar.gz: 47da55dc51b8f5b38e6dae281560f0dc880c3a58a95baea0689d4b0fd82536f7
3
+ metadata.gz: f754910573873164cbdb81ddd5191e9c472db9b482df20072e5ea4e7c5473f95
4
+ data.tar.gz: 764369b4f40ff33aaf2576721d58fafe54a7bda777e00e84387b008a3a9d006a
5
5
  SHA512:
6
- metadata.gz: e21748dad35cd17a28452b9593dc08b015544d08d68fb615c4ae71e7681a3ac7790a2a95f0eec2aaad389d46fe95fb63d969d8043b668f695e39e7903afef640
7
- data.tar.gz: e91e1e12645b2af6d0079209c690e08b4dfdb0e951cf99cf5b1c01b2b831e79d890a3eec1450be09434d14d448cae9cc58d93ccf61aded66e246da9c711bc670
6
+ metadata.gz: f0fa13de63f9418c5acec24af7e645927ee502e63401e36288b4d478e8d17d5d41bfe762e4055dc9fe67915c6b466d755a61ceb885a9f0c9e31d4d8a6abe4f23
7
+ data.tar.gz: a4c00a408fced68c41d503711ee5204df1bd978d14fe0b5b9a02a6d53e3b54a1a8381a930b87c86c5cd67ce239711a7b002e8931bcb184edab2f2fb1b1849e3d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,3 @@
1
- ## [Unreleased]
2
-
3
1
  ## [0.1.0.alpha] - 2023-06-17
4
2
 
5
3
  - Initial release
@@ -41,3 +39,17 @@
41
39
  - Require thor >= 1.4 (CVE-2025-54314) and Ruby >= 3.3
42
40
  - Add `version` command (`--version`, `-v`)
43
41
  - CI: pin third-party action to a commit SHA, restrict token permissions, test Ruby 3.3–3.4
42
+
43
+ ## [0.5.0] - 2026-10-03
44
+
45
+ - Add `branches` command with last commit, upstream (ahead, behind, gone) and merge status, filters `--merged`, `--gone`, `--stale DAYS`
46
+ - Commands accept arguments: `checkout BRANCH`, `delete BRANCH...`, `reset COMMIT`
47
+ - Add `--json`, `--yes` and `--dry-run` options to every command
48
+ - Never wait for input without a terminal: exit with code 2 and explain what to pass
49
+ - `delete` keeps unmerged branches unless `--force` is given (breaking: it used to always force-delete) and also protects the default branch
50
+ - Detect the default branch from `origin/HEAD`, falling back to `main` or `master`
51
+ - Add `reset --soft`
52
+ - JSON contract version 1: every `--json` run prints one envelope (`schema`, `command`, `ok`, `status`, `dry_run`, `plan`, `result`, `error`) defined in `schema/v1.json`; stable error codes
53
+ - Results include what is needed to revert: deleted branch SHAs, the previous branch after `checkout`, the previous commit after `reset`
54
+ - Add `schema` command that prints the JSON Schema
55
+ - Internal: new `Repo` service layer with `Branch` and `Commit` records; integration specs run against real temporary git repositories
data/README.md CHANGED
@@ -4,7 +4,9 @@
4
4
 
5
5
  ⚠️ **This project is still under development**: Use at your own risk!
6
6
 
7
- This gem allows you to use some of the most common git commands in a more user intuitive way.
7
+ A safety net and cleanup tool for git repositories that AI coding agents work in. It works on plain git, with nothing new to adopt, and every command can be run by a person in a terminal or by an agent with a stable JSON contract.
8
+
9
+ See [docs/ROADMAP.md](docs/ROADMAP.md) for where the project is going.
8
10
 
9
11
  ## Installation
10
12
 
@@ -32,14 +34,60 @@ Run `gitflash` inside a git repository to get a list with the available commands
32
34
 
33
35
  | Command | Description |
34
36
  | --- | --- |
35
- | `gitflash checkout` | Select a local branch and check it out |
36
- | `gitflash delete` | Select local branches (excluding the current branch and main/master) and force-delete them |
37
- | `gitflash reset` | Select one of the latest 100 commits and reset to it (mixed reset) |
38
- | `gitflash reset --hard` | Same as `reset`, but discard all current changes after confirmation |
37
+ | `gitflash branches` | List local branches with last commit, upstream status and merge status. Filter with `--merged`, `--gone` or `--stale DAYS` |
38
+ | `gitflash checkout [BRANCH]` | Check out a branch, or pick one from a list |
39
+ | `gitflash delete [BRANCH...]` | Delete branches, or pick them from a list. The current, default, `main` and `master` branches are protected. Unmerged branches are kept unless you pass `--force` |
40
+ | `gitflash reset [COMMIT]` | Reset to a commit, or pick one of the latest 100. Mixed by default; `--soft` keeps changes staged, `--hard` discards them after confirmation |
41
+ | `gitflash schema` | Print the JSON Schema of the `--json` output |
39
42
  | `gitflash version` | Print the installed version (also `--version`, `-v`) |
40
43
 
44
+ Without an argument, a command shows an interactive list. With arguments it runs directly, which also works in scripts.
45
+
46
+ Options for every command:
47
+
48
+ | Option | Effect |
49
+ | --- | --- |
50
+ | `--json` | Print JSON instead of text. Never shows lists or prompts |
51
+ | `--yes`, `-y` | Skip confirmation prompts |
52
+ | `--dry-run` | Show what would happen without changing anything |
53
+
41
54
  Run `gitflash help <command>` for details on a command.
42
55
 
56
+ ### Scripts and AI agents
57
+
58
+ Without a terminal (for example in a script, CI or an AI coding agent), gitflash never waits for input:
59
+
60
+ - A command that needs a list selection fails with exit code 2 and says which argument to pass.
61
+ - A destructive action (`delete`, `reset --hard`) fails with exit code 2 and returns the plan unless you pass `--yes`.
62
+
63
+ With `--json`, every command prints one object in the same envelope, defined in [schema/v1.json](schema/v1.json) (also printed by `gitflash schema`):
64
+
65
+ ```json
66
+ {
67
+ "schema": 1,
68
+ "command": "delete",
69
+ "ok": true,
70
+ "status": "done",
71
+ "dry_run": false,
72
+ "plan": { "branches": ["old-feature"], "force": false },
73
+ "result": { "deleted": [{ "branch": "old-feature", "sha": "3551cba" }], "failed": [] }
74
+ }
75
+ ```
76
+
77
+ - `status`: `done`, `planned` (`--dry-run`), `noop`, `cancelled`, `failed`, `confirmation_required` or `error`. `ok` is true for the first four.
78
+ - `result` includes what you need to revert: the SHA of each deleted branch (`git branch NAME SHA`), the previous branch after `checkout`, the previous commit after `reset`.
79
+ - `error.code` is a stable identifier such as `unknown_branch`, `protected_branch` or `confirmation_required`.
80
+
81
+ ```bash
82
+ gitflash branches --merged --json
83
+ ```
84
+
85
+ ```bash
86
+ gitflash delete old-feature another-branch --yes --json
87
+ ```
88
+
89
+ Exit codes: `0` success, `1` a git command failed (for example a branch was not fully merged), `2` invalid usage or confirmation required.
90
+
43
91
  ## Development
44
92
 
45
93
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -1,33 +1,48 @@
1
+ branches:
2
+ short: 'List local branches with merge and upstream status'
3
+ long: |
4
+ Lists local branches with their last commit, upstream status (ahead, behind, gone)
5
+ and whether they are merged into the default branch.
6
+
7
+ The default branch is the one origin/HEAD points to, else main or master.
8
+
9
+ Filters (a branch is listed when it matches any of them):
10
+ --merged merged into the default branch
11
+ --gone upstream branch was deleted on the remote
12
+ --stale DAYS no commits for DAYS days
13
+
14
+ Pass --json for machine-readable output.
15
+
1
16
  checkout:
2
- short: 'Checkout the selected branch'
17
+ short: 'Checkout a branch'
3
18
  long: |
4
- Presents a list with all the available local branches.
19
+ Checks out BRANCH when given.
5
20
 
6
- You can use the arrow keys to navigate through the branches.
7
- Type for quick searching a specific branch.
8
- Press `enter` to choose and checkout the branch.
9
- Press `ctrl+c` to cancel the operation.
21
+ Without BRANCH, presents a list with all the available local branches:
22
+ use the arrow keys to navigate, type to search, press `enter` to checkout
23
+ and `ctrl+c` to cancel. Without a terminal, BRANCH is required.
10
24
 
11
25
  delete:
12
- short: 'Delete the selected branches'
26
+ short: 'Delete branches'
13
27
  long: |
14
- Presents a list with all the available local branches (without current or main/master).
28
+ Deletes the given BRANCHES. Without them, presents a list with the local branches
29
+ (without the current, default, main or master branch): press `space` to choose
30
+ branches and `enter` to confirm. Without a terminal, BRANCHES are required.
31
+
32
+ Branches with unmerged changes are kept unless --force is given.
15
33
 
16
- You can use the arrow keys to navigate through the branches.
17
- Type for quick searching a specific branch.
18
- Press `space` to choose multiple branches.
19
- Press `enter` to confirm selection and delete branches.
20
- Press `ctrl+c` to cancel the operation.
34
+ Asks for confirmation before deleting. Pass --yes to skip it, which is required
35
+ without a terminal, or --dry-run to only show what would be deleted.
21
36
 
22
37
  reset:
23
- short: 'Reset your code to the selected commit'
38
+ short: 'Reset your code to a commit'
24
39
  long: |
25
- By default performs a mixed reset: your changes are kept as unstaged changes.
26
- Pass --hard to discard all your current changes.
40
+ Resets the current branch to COMMIT when given.
27
41
 
28
- Presents a list with the latest 100 commits of the current branch.
42
+ Without COMMIT, presents a list with the latest 100 commits of the current branch:
43
+ use the arrow keys to navigate, type to search, press `enter` to reset to that commit
44
+ and `ctrl+c` to cancel. Without a terminal, COMMIT is required.
29
45
 
30
- You can use the arrow keys to navigate through the commits.
31
- Type for quick searching a specific commit.
32
- Press `enter` to confirm selection and reset to that commit.
33
- Press `ctrl+c` to cancel the operation.
46
+ By default performs a mixed reset: your changes are kept as unstaged changes.
47
+ Pass --soft to keep them staged, or --hard to discard them. A hard reset asks for
48
+ confirmation; pass --yes to skip it, which is required without a terminal.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Gitflash
6
+ # A local branch as reported by `git for-each-ref`
7
+ Branch = Data.define(
8
+ :name, :sha, :current, :default, :upstream, :upstream_gone, :ahead, :behind,
9
+ :merged, :last_commit_at, :last_commit_author, :last_commit_subject
10
+ ) do
11
+ # Parses one line produced with Repo::BRANCH_FORMAT.
12
+ # `merged_names` is nil when merge status is unknown.
13
+ def self.parse(line, default_branch: nil, merged_names: nil)
14
+ name, sha, head, upstream, track, date, author, subject = line.split(Repo::SEPARATOR, 8)
15
+
16
+ new(
17
+ name: name,
18
+ sha: sha,
19
+ current: head == '*',
20
+ default: name == default_branch,
21
+ upstream: upstream.to_s.empty? ? nil : upstream,
22
+ upstream_gone: track == 'gone',
23
+ ahead: track.to_s[/ahead (\d+)/, 1].to_i,
24
+ behind: track.to_s[/behind (\d+)/, 1].to_i,
25
+ merged: merged_names&.include?(name),
26
+ last_commit_at: Time.iso8601(date),
27
+ last_commit_author: author,
28
+ last_commit_subject: subject.to_s
29
+ )
30
+ end
31
+
32
+ alias_method :current?, :current
33
+ alias_method :default?, :default
34
+ alias_method :upstream_gone?, :upstream_gone
35
+
36
+ def stale?(days, now: Time.now)
37
+ last_commit_at < now - (days * 86_400)
38
+ end
39
+
40
+ def to_h
41
+ super.merge(last_commit_at: last_commit_at.iso8601)
42
+ end
43
+ end
44
+ end
data/lib/gitflash/cli.rb CHANGED
@@ -6,33 +6,53 @@ module Gitflash
6
6
  class Cli < Thor
7
7
  extend Configuration::Descriptions
8
8
 
9
+ SCHEMA_PATH = File.expand_path('../../schema/v1.json', __dir__)
10
+
9
11
  def self.exit_on_failure?
10
12
  true
11
13
  end
12
14
 
13
- desc 'checkout', descriptions.checkout.short
14
- long_desc descriptions.checkout.long
15
+ class_option :json, type: :boolean, default: false,
16
+ desc: 'Print JSON instead of text; never prompts'
17
+ class_option :yes, type: :boolean, aliases: '-y', default: false,
18
+ desc: 'Skip confirmation prompts'
19
+ class_option :dry_run, type: :boolean, default: false,
20
+ desc: 'Show what would happen without changing anything'
15
21
 
16
- def checkout
17
- ensure_git_repo!
18
- branches? ? checkout_branch : prompt.ok('You only have one branch!')
22
+ desc 'branches', descriptions.branches.short
23
+ long_desc descriptions.branches.long
24
+ option :merged, type: :boolean, desc: 'Branches merged into the default branch'
25
+ option :gone, type: :boolean, desc: 'Branches whose upstream branch was deleted'
26
+ option :stale, type: :numeric, banner: 'DAYS', desc: 'Branches without commits for DAYS days'
27
+ def branches
28
+ run_command(Commands::Branches)
19
29
  end
20
30
 
21
- desc 'delete', descriptions.delete.short
22
- long_desc descriptions.delete.long
31
+ desc 'checkout [BRANCH]', descriptions.checkout.short
32
+ long_desc descriptions.checkout.long
33
+ def checkout(branch = nil)
34
+ run_command(Commands::Checkout, *[branch].compact)
35
+ end
23
36
 
24
- def delete
25
- ensure_git_repo!
26
- branches? ? delete_branch : prompt.ok('You only have one branch!')
37
+ desc 'delete [BRANCH...]', descriptions.delete.short
38
+ long_desc descriptions.delete.long
39
+ option :force, type: :boolean, default: false,
40
+ desc: 'Delete branches even if they have unmerged changes'
41
+ def delete(*branches)
42
+ run_command(Commands::Delete, *branches)
27
43
  end
28
44
 
29
- desc 'reset', descriptions.reset.short
45
+ desc 'reset [COMMIT]', descriptions.reset.short
30
46
  long_desc descriptions.reset.long
47
+ option :hard, type: :boolean, default: false, desc: 'Discard all current changes'
48
+ option :soft, type: :boolean, default: false, desc: 'Keep changes staged'
49
+ def reset(commit = nil)
50
+ run_command(Commands::Reset, *[commit].compact)
51
+ end
31
52
 
32
- option :hard, type: :boolean, default: false, desc: 'Perform a hard reset'
33
- def reset
34
- ensure_git_repo!
35
- commits? ? reset_to_commit : prompt.ok('You only have one commit!')
53
+ desc 'schema', 'Print the JSON Schema of the --json output'
54
+ def schema
55
+ puts File.read(SCHEMA_PATH)
36
56
  end
37
57
 
38
58
  desc 'version', 'Print the gitflash version'
@@ -44,97 +64,19 @@ module Gitflash
44
64
 
45
65
  private
46
66
 
47
- def checkout_branch
48
- selection = prompt.select(
49
- 'Select a branch to checkout',
50
- branches,
51
- **checkout_options
52
- )
53
-
54
- run_git('checkout') { git.checkout(selection) }
55
- end
56
-
57
- def delete_branch
58
- choices = branches(options: { master: false, current: false })
59
- return prompt.ok('No branches available to delete') if choices.empty?
60
-
61
- selection = prompt.multi_select('Select branches to delete', choices)
62
- return prompt.ok('No branches selected') if selection.empty?
63
-
64
- warning_message = <<~TEXT
65
- You are about to permanently delete the following branches even if they have unmerged changes:
66
-
67
- #{selection.map { |br| "* #{br}" }.join("\n")}
68
- TEXT
69
-
70
- proceed_with_warning(warning_message) do
71
- run_git('delete') { git.delete(selection) }
72
- end
73
- end
74
-
75
- def reset_to_commit
76
- selection = prompt.select('Select a commit to reset to', branch_commits)
77
- reset_options = { commit_hash: selection, hard: options[:hard] }
78
-
79
- if options[:hard]
80
- warning_message = 'You are about to reset your branch and lose all your current changes'
81
- proceed_with_warning(warning_message) { run_git('reset') { git.reset(**reset_options) } }
82
- else
83
- run_git('reset') { git.reset(**reset_options) }
84
- end
85
- end
86
-
87
- def proceed_with_warning(message, &)
88
- result = prompt.proceed_with_warning(message, &)
89
- puts result if result == 'Exited'
90
- end
91
-
92
- def run_git(command)
93
- raise Thor::Error, "git #{command} failed" unless yield
94
- end
95
-
96
- def ensure_git_repo!
97
- git.inside_work_tree!
98
- rescue Git::CommandError
99
- raise Thor::Error, 'Not a git repository'
100
- end
101
-
102
- def branches?
103
- all_branches && all_branches.size > 1
104
- end
105
-
106
- def commits?
107
- branch_commits && branch_commits.size > 1
108
- end
109
-
110
- def prompt
111
- @prompt ||= Prompt.create
112
- end
113
-
114
- def checkout_options
115
- {}.tap do |opt|
116
- opt[:default] = current unless current.to_s.empty?
117
- end
118
- end
119
-
120
- def branches(options: {})
121
- git.local_branches(**options)
122
- end
123
-
124
- def all_branches
125
- @all_branches ||= git.all_local_branches
126
- end
127
-
128
- def current
129
- @current ||= git.current_branch
130
- end
131
-
132
- def branch_commits
133
- @branch_commits ||= git.branch_commits
67
+ def run_command(command_class, *)
68
+ ui = build_ui(command_class.name.split('::').last.downcase)
69
+ repo = Repo.new
70
+ repo.ensure_work_tree!
71
+ status = command_class.new(repo: repo, ui: ui, options: options).call(*)
72
+ exit(status) unless status.zero?
73
+ rescue Error => e
74
+ ui.error(e)
75
+ exit(e.exit_code)
134
76
  end
135
77
 
136
- def git
137
- Gitflash::Git::Wrapper
78
+ def build_ui(command)
79
+ Ui.new(command: command, json: options[:json], yes: options[:yes], dry_run: options[:dry_run])
138
80
  end
139
81
  end
140
82
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Shared setup for commands. `call` returns the process exit status.
6
+ class Base
7
+ def initialize(repo:, ui:, options: {})
8
+ @repo = repo
9
+ @ui = ui
10
+ @options = options
11
+ end
12
+
13
+ private
14
+
15
+ attr_reader :repo, :ui, :options
16
+
17
+ def require_interactive!(message)
18
+ usage_error!('input_required', message) unless ui.interactive?
19
+ end
20
+
21
+ def usage_error!(code, message)
22
+ raise UsageError.new(message, code: code)
23
+ end
24
+
25
+ def git_error!(command, result)
26
+ raise Error.new("git #{command} failed:\n#{result.output}", code: 'git_failed')
27
+ end
28
+
29
+ def planned(plan, text)
30
+ ui.report(status: 'planned', plan: plan, text: text)
31
+ end
32
+
33
+ def cancelled(plan)
34
+ ui.report(status: 'cancelled', plan: plan, text: 'Exited')
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Lists local branches, optionally filtered to merged, gone or stale ones
6
+ class Branches < Base
7
+ def call
8
+ branches = filter(repo.branches(merged_status: true))
9
+ result = { default_branch: repo.default_branch, branches: branches.map(&:to_h) }
10
+ ui.report(status: 'done', result: result, text: table(branches))
11
+ end
12
+
13
+ private
14
+
15
+ # A branch is listed when it matches any of the given filters
16
+ def filter(branches)
17
+ filters = active_filters
18
+ return branches if filters.empty?
19
+
20
+ branches.select { |branch| filters.any? { |matches| matches.call(branch) } }
21
+ end
22
+
23
+ def active_filters
24
+ filters = {
25
+ merged: ->(branch) { branch.merged && !branch.default? },
26
+ gone: lambda(&:upstream_gone?),
27
+ stale: ->(branch) { branch.stale?(options[:stale]) }
28
+ }
29
+ filters.select { |name, _filter| options[name] }.values
30
+ end
31
+
32
+ def table(branches)
33
+ return 'No branches match.' if branches.empty?
34
+
35
+ width = branches.map { |branch| branch.name.length }.max
36
+ branches.map { |branch| row(branch, width) }.join("\n")
37
+ end
38
+
39
+ def row(branch, width)
40
+ marker = branch.current? ? '*' : ' '
41
+ columns = [
42
+ "#{marker} #{branch.name.ljust(width)}",
43
+ age(branch.last_commit_at).ljust(12),
44
+ status(branch).ljust(22),
45
+ branch.last_commit_subject
46
+ ]
47
+ columns.join(' ').rstrip
48
+ end
49
+
50
+ def status(branch)
51
+ {
52
+ 'default' => branch.default?,
53
+ 'merged' => branch.merged && !branch.default?,
54
+ 'gone' => branch.upstream_gone?,
55
+ "ahead #{branch.ahead}" => branch.ahead.positive?,
56
+ "behind #{branch.behind}" => branch.behind.positive?
57
+ }.select { |_label, shown| shown }.keys.join(', ')
58
+ end
59
+
60
+ def age(time)
61
+ days = ((Time.now - time) / 86_400).floor
62
+ return 'today' if days < 1
63
+
64
+ days == 1 ? '1 day ago' : "#{days} days ago"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Checks out a local branch given as an argument or picked from a menu
6
+ class Checkout < Base
7
+ def call(name = nil)
8
+ branches = repo.branches
9
+ current = branches.find(&:current?)&.name
10
+
11
+ if name.nil?
12
+ require_interactive!('Pass the branch to check out: gitflash checkout BRANCH')
13
+ return noop(current, 'You only have one branch!') if branches.size < 2
14
+
15
+ name = pick(branches, current)
16
+ end
17
+
18
+ checkout(name, branches.map(&:name), current)
19
+ end
20
+
21
+ private
22
+
23
+ def pick(branches, current)
24
+ select_options = current.nil? ? {} : { default: current }
25
+ ui.select('Select a branch to checkout', branches.map(&:name), **select_options)
26
+ end
27
+
28
+ def checkout(name, names, current)
29
+ usage_error!('unknown_branch', "Unknown branch '#{name}'") unless names.include?(name)
30
+ return noop(name, "Already on '#{name}'") if name == current
31
+
32
+ plan = { branch: name }
33
+ return planned(plan, "Would check out '#{name}'") if ui.dry_run?
34
+
35
+ result = repo.checkout(name)
36
+ git_error!('checkout', result) unless result.success?
37
+
38
+ ui.report(status: 'done', plan: plan, result: { branch: name, previous_branch: current },
39
+ text: "Switched to branch '#{name}'")
40
+ end
41
+
42
+ def noop(branch, text)
43
+ ui.report(status: 'noop', result: { branch: branch, previous_branch: branch }, text: text)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Deletes local branches given as arguments or picked from a menu.
6
+ # Uses `git branch -d` (merged branches only) unless --force is given.
7
+ # The result lists each deleted branch with its last commit, so it can be recreated.
8
+ class Delete < Base
9
+ PROTECTED_NAMES = %w[main master].freeze
10
+
11
+ def call(*names)
12
+ branches = repo.branches
13
+ candidates = branches.reject { |branch| protected?(branch) }.map(&:name)
14
+
15
+ names = pick(candidates) if names.empty?
16
+ return 0 if names.nil?
17
+
18
+ validate!(names.uniq, branches)
19
+ delete(names.uniq, branches.to_h { |branch| [branch.name, branch.sha] })
20
+ end
21
+
22
+ private
23
+
24
+ # Branches picked from the menu, or nil after telling the user there is nothing to delete
25
+ def pick(candidates)
26
+ require_interactive!('Pass the branches to delete: gitflash delete BRANCH...')
27
+ return report_nothing('No branches available to delete') if candidates.empty?
28
+
29
+ names = ui.multi_select('Select branches to delete', candidates)
30
+ names.empty? ? report_nothing('No branches selected') : names
31
+ end
32
+
33
+ def force?
34
+ options[:force] ? true : false
35
+ end
36
+
37
+ def protected?(branch)
38
+ branch.current? || branch.default? || PROTECTED_NAMES.include?(branch.name)
39
+ end
40
+
41
+ def validate!(names, branches)
42
+ by_name = branches.to_h { |branch| [branch.name, branch] }
43
+ unknown = names.reject { |name| by_name.key?(name) }
44
+ usage_error!('unknown_branch', "Unknown branch: #{unknown.join(', ')}") if unknown.any?
45
+
46
+ refused = names.select { |name| protected?(by_name[name]) }
47
+ return if refused.empty?
48
+
49
+ usage_error!('protected_branch',
50
+ "Refusing to delete protected branches: #{refused.join(', ')} " \
51
+ '(current, default, main and master are protected)')
52
+ end
53
+
54
+ def delete(names, shas)
55
+ plan = { branches: names, force: force? }
56
+ return planned(plan, "Would delete:\n#{bullets(names)}") if ui.dry_run?
57
+ return cancelled(plan) unless ui.confirm?(summary(names), plan: plan)
58
+
59
+ outcomes = names.map { |name| [name, repo.delete_branch(name, force: force?)] }
60
+ report_results(plan, outcomes, shas)
61
+ end
62
+
63
+ def summary(names)
64
+ mode = force? ? 'even if they have unmerged changes' : 'if they are fully merged'
65
+ "You are about to delete the following branches #{mode}:\n\n#{bullets(names)}"
66
+ end
67
+
68
+ def bullets(names)
69
+ names.map { |name| "* #{name}" }.join("\n")
70
+ end
71
+
72
+ def report_nothing(text)
73
+ ui.report(status: 'noop', result: { deleted: [], failed: [] }, text: text)
74
+ nil
75
+ end
76
+
77
+ def report_results(plan, outcomes, shas)
78
+ deleted, failed = outcomes.partition { |_name, result| result.success? }
79
+ result = {
80
+ deleted: deleted.map { |name, _result| { branch: name, sha: shas[name] } },
81
+ failed: failed.map { |name, result| { branch: name, error: result.output } }
82
+ }
83
+ ui.report(status: failed.empty? ? 'done' : 'failed', plan: plan, result: result,
84
+ error: failure(failed, outcomes), text: results_text(result))
85
+ end
86
+
87
+ def failure(failed, outcomes)
88
+ return nil if failed.empty?
89
+
90
+ message = "#{failed.size} of #{outcomes.size} branches were not deleted"
91
+ { code: 'git_failed', message: message, exit_code: 1 }
92
+ end
93
+
94
+ def force_hint?(result)
95
+ result[:failed].any? && !force?
96
+ end
97
+
98
+ def results_text(result)
99
+ lines = result[:deleted].map { |row| "Deleted branch #{row[:branch]} (was #{row[:sha]})" }
100
+ lines += result[:failed].map { |row| "Not deleted #{row[:branch]}: #{row[:error]}" }
101
+ lines << 'Use --force to delete branches with unmerged changes.' if force_hint?(result)
102
+ lines.join("\n")
103
+ end
104
+ end
105
+ end
106
+ end