gitflash 0.3.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 +55 -0
- data/README.md +63 -4
- data/bin/gitflash +2 -1
- data/command_descriptions.yml +36 -21
- data/lib/gitflash/branch.rb +44 -0
- data/lib/gitflash/cli.rb +50 -86
- 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/configuration.rb +3 -1
- data/lib/gitflash/confirmation_required.rb +19 -0
- data/lib/gitflash/error.rb +23 -0
- data/lib/gitflash/git/bash_command.rb +15 -4
- data/lib/gitflash/git/command_error.rb +11 -0
- data/lib/gitflash/prompt.rb +2 -2
- 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 +30 -29
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/lib/gitflash/git/wrapper.rb +0 -80
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
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
## [0.1.0.alpha] - 2023-06-17
|
|
2
|
+
|
|
3
|
+
- Initial release
|
|
4
|
+
|
|
5
|
+
## [0.1.1.alpha] - 2023-10-28
|
|
6
|
+
|
|
7
|
+
- Fix delete method
|
|
8
|
+
- Added and updated test
|
|
9
|
+
- bug fixes
|
|
10
|
+
|
|
11
|
+
## [0.1.2.alpha] - 2023-11-4
|
|
12
|
+
|
|
13
|
+
- Refactors and code improvements
|
|
14
|
+
- Zeitwerk warning suppress
|
|
15
|
+
- Update README
|
|
16
|
+
- Bug fixes
|
|
17
|
+
|
|
18
|
+
## [0.2.0.alpha] - 2023-12-9
|
|
19
|
+
|
|
20
|
+
- Add reset command
|
|
21
|
+
- Code improvements
|
|
22
|
+
|
|
23
|
+
## [0.3.0] - 2025-10-10
|
|
24
|
+
|
|
25
|
+
- Fix reset method
|
|
26
|
+
- Update to Ruby version 3.3.2
|
|
27
|
+
- Bug fixes
|
|
28
|
+
|
|
29
|
+
## [0.4.0] - 2026-09-24
|
|
30
|
+
|
|
31
|
+
- Run git commands without a shell and raise on failure
|
|
32
|
+
- List branches with `git for-each-ref`; detached HEAD no longer shows as a branch
|
|
33
|
+
- List the latest 100 commits for `reset`, unaffected by `log.decorate`
|
|
34
|
+
- Exit with an error when a git command fails or outside a git repository
|
|
35
|
+
- `delete` does nothing when no branches are available or selected
|
|
36
|
+
- `reset --hard` prints `Exited` when declined; fix single-commit message
|
|
37
|
+
- Correct `reset` help text
|
|
38
|
+
- Portable `bin/gitflash` shebang
|
|
39
|
+
- Require thor >= 1.4 (CVE-2025-54314) and Ruby >= 3.3
|
|
40
|
+
- Add `version` command (`--version`, `-v`)
|
|
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
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/rb/gitflash)
|
|
4
4
|
|
|
5
|
-
⚠️ **This project is
|
|
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
|
|
|
@@ -24,10 +26,67 @@ Or install it yourself as:
|
|
|
24
26
|
|
|
25
27
|
## Requirements
|
|
26
28
|
|
|
27
|
-
This gem requires Ruby 3.3.
|
|
29
|
+
This gem requires Ruby 3.3+ and git.
|
|
30
|
+
|
|
28
31
|
## Usage
|
|
29
32
|
|
|
30
|
-
Run `gitflash` to get a list with the available commands and their description.
|
|
33
|
+
Run `gitflash` inside a git repository to get a list with the available commands and their description.
|
|
34
|
+
|
|
35
|
+
| Command | Description |
|
|
36
|
+
| --- | --- |
|
|
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 |
|
|
42
|
+
| `gitflash version` | Print the installed version (also `--version`, `-v`) |
|
|
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
|
+
|
|
54
|
+
Run `gitflash help <command>` for details on a command.
|
|
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.
|
|
31
90
|
|
|
32
91
|
## Development
|
|
33
92
|
|
data/bin/gitflash
CHANGED
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
|
-
|
|
40
|
+
Resets the current branch to COMMIT when given.
|
|
26
41
|
|
|
27
|
-
|
|
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.
|
|
28
45
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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,113 +6,77 @@ 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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
desc 'delete', descriptions.delete.short
|
|
21
|
-
long_desc descriptions.delete.long
|
|
22
|
-
|
|
23
|
-
def delete
|
|
24
|
-
branches? ? delete_branch : prompt.ok('You only have one branch!')
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
desc 'reset', descriptions.reset.short
|
|
28
|
-
long_desc descriptions.reset.long
|
|
29
|
-
|
|
30
|
-
option :hard, type: :boolean, default: false, desc: 'Perform a hard reset'
|
|
31
|
-
def reset
|
|
32
|
-
commits? ? reset_to_commit : prompt.ok('You only have one branch!')
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
|
|
37
|
-
def checkout_branch
|
|
38
|
-
selection = prompt.select(
|
|
39
|
-
'Select a branch to checkout',
|
|
40
|
-
branches,
|
|
41
|
-
**checkout_options
|
|
42
|
-
)
|
|
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'
|
|
43
21
|
|
|
44
|
-
|
|
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)
|
|
45
29
|
end
|
|
46
30
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
warning_message = <<~TEXT
|
|
54
|
-
You are about to permanently delete the following branches even if they have unmerged changes:
|
|
55
|
-
|
|
56
|
-
#{selection.map { |br| "* #{br}" }.join("\n")}
|
|
57
|
-
TEXT
|
|
58
|
-
|
|
59
|
-
prompt_proceed_warning(warning_message) { git.delete(selection) }
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def reset_to_commit
|
|
63
|
-
selection = prompt.select('Select a commit to reset to', branch_commits)
|
|
64
|
-
reset_options = { commit_hash: selection, hard: options[:hard] }
|
|
65
|
-
|
|
66
|
-
if options[:hard]
|
|
67
|
-
warning_message = 'You are about to reset your branch and lose all your current changes'
|
|
68
|
-
prompt.proceed_with_warning(warning_message) { git.reset(**reset_options) }
|
|
69
|
-
else
|
|
70
|
-
git.reset(**reset_options)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def branches?
|
|
75
|
-
all_branches && all_branches.size > 1
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def commits?
|
|
79
|
-
branch_commits && branch_commits.size > 1
|
|
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)
|
|
80
35
|
end
|
|
81
36
|
|
|
82
|
-
|
|
83
|
-
|
|
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)
|
|
84
43
|
end
|
|
85
44
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
45
|
+
desc 'reset [COMMIT]', descriptions.reset.short
|
|
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)
|
|
90
51
|
end
|
|
91
52
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
end
|
|
53
|
+
desc 'schema', 'Print the JSON Schema of the --json output'
|
|
54
|
+
def schema
|
|
55
|
+
puts File.read(SCHEMA_PATH)
|
|
96
56
|
end
|
|
97
57
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
end
|
|
58
|
+
desc 'version', 'Print the gitflash version'
|
|
59
|
+
map %w[--version -v] => :version
|
|
101
60
|
|
|
102
|
-
def
|
|
103
|
-
|
|
61
|
+
def version
|
|
62
|
+
puts VERSION
|
|
104
63
|
end
|
|
105
64
|
|
|
106
|
-
|
|
107
|
-
@current ||= git.current_branch
|
|
108
|
-
end
|
|
65
|
+
private
|
|
109
66
|
|
|
110
|
-
def
|
|
111
|
-
|
|
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)
|
|
112
76
|
end
|
|
113
77
|
|
|
114
|
-
def
|
|
115
|
-
|
|
78
|
+
def build_ui(command)
|
|
79
|
+
Ui.new(command: command, json: options[:json], yes: options[:yes], dry_run: options[:dry_run])
|
|
116
80
|
end
|
|
117
81
|
end
|
|
118
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
|