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 +4 -4
- data/CHANGELOG.md +14 -2
- data/README.md +53 -5
- data/command_descriptions.yml +36 -21
- data/lib/gitflash/branch.rb +44 -0
- data/lib/gitflash/cli.rb +46 -104
- data/lib/gitflash/commands/base.rb +38 -0
- data/lib/gitflash/commands/branches.rb +68 -0
- data/lib/gitflash/commands/checkout.rb +47 -0
- data/lib/gitflash/commands/delete.rb +106 -0
- data/lib/gitflash/commands/reset.rb +67 -0
- data/lib/gitflash/commit.rb +22 -0
- data/lib/gitflash/confirmation_required.rb +19 -0
- data/lib/gitflash/error.rb +23 -0
- data/lib/gitflash/git/bash_command.rb +8 -6
- data/lib/gitflash/git/command_error.rb +5 -1
- data/lib/gitflash/repo.rb +116 -0
- data/lib/gitflash/result.rb +8 -0
- data/lib/gitflash/ui.rb +93 -0
- data/lib/gitflash/usage_error.rb +14 -0
- data/lib/gitflash/version.rb +1 -1
- data/schema/v1.json +172 -0
- metadata +15 -2
- data/lib/gitflash/git/wrapper.rb +0 -86
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f754910573873164cbdb81ddd5191e9c472db9b482df20072e5ea4e7c5473f95
|
|
4
|
+
data.tar.gz: 764369b4f40ff33aaf2576721d58fafe54a7bda777e00e84387b008a3a9d006a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
36
|
-
| `gitflash
|
|
37
|
-
| `gitflash
|
|
38
|
-
| `gitflash reset
|
|
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.
|
data/command_descriptions.yml
CHANGED
|
@@ -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
|
|
17
|
+
short: 'Checkout a branch'
|
|
3
18
|
long: |
|
|
4
|
-
|
|
19
|
+
Checks out BRANCH when given.
|
|
5
20
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
26
|
+
short: 'Delete branches'
|
|
13
27
|
long: |
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
38
|
+
short: 'Reset your code to a commit'
|
|
24
39
|
long: |
|
|
25
|
-
|
|
26
|
-
Pass --hard to discard all your current changes.
|
|
40
|
+
Resets the current branch to COMMIT when given.
|
|
27
41
|
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 '
|
|
22
|
-
long_desc descriptions.
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
33
|
-
def
|
|
34
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
137
|
-
|
|
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
|