gitflash 0.5.1 → 0.6.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: 86ac16bd9cd3c64cd98305a6423e67c05187040d8caba798d132a4f985ee28df
4
- data.tar.gz: 8da24e90611e20296f70ca3cadab5a77f697c97ff43cf535b9f51b1bd40a66be
3
+ metadata.gz: 5a9dbc4a8ff348766f4e0a7911d451f372a4ab4b355e55d9f260d453a668948f
4
+ data.tar.gz: 687787d41cf614c244512ac27a105bc34cf1d75a169d6eec14f56c80a3ab1c88
5
5
  SHA512:
6
- metadata.gz: c573a9265e4558a9d502840cb7b65b0c07c6e6f640c74bde499ed1b4ff160fd8aeb101a9ecd95b692a2170c812d0a6afd14d8abe8629d46b8e65732a7622384c
7
- data.tar.gz: 53e55b8afcc5f32cd1b0fa238e0f7db356b4e919d57f606ebec37ee3a86539fff2dd0afaef3b5bf6f295f235e9f4452ec1b5bb3dee5dc7fd1df964cb3e6f3d85
6
+ metadata.gz: d7f50c47a77e7efe0410f287dfe6c7a447187300c3974512aeb17a52806bc4e139148a45f9499118cc6286c7a636f4209f476eea8e3e0eb23cb13a5c75eac3a0
7
+ data.tar.gz: 271acb28eabeb2ffa6efb6e9bc9449d90dc02ab650c8d57f8f54b1330bc164f921f7bd18b9ebdc7db0094e07347e05a631708f42c4e9eff4b09d73607bf3d09f
data/CHANGELOG.md CHANGED
@@ -62,3 +62,14 @@ Security fixes for names and text that come from the repository:
62
62
  - Branch and default-branch detection use full ref names. A tag named like `origin/<default>` could change the short name of `origin/HEAD`, so the default branch lost its deletion protection and its merge status; `origin/HEAD` must now point inside `refs/remotes/origin/`
63
63
  - `delete` passes `--` before the branch name; `resolve_commit` refuses references that start with a dash
64
64
  - Text output shows terminal control characters of repository-controlled text (commit subjects, git errors) as escapes such as `\x1B`, so a crafted commit subject cannot move the cursor, rewrite output or set the clipboard (OSC 52). JSON output keeps the original text
65
+
66
+ ## [0.6.0] - 2026-10-05
67
+
68
+ - Snapshots: gitflash saves branches, HEAD, tracked, staged and untracked files and stash entries before every change it makes, as git objects under `refs/gitflash/snapshots/` (the working tree is never touched; untracked files over 50 MB are skipped)
69
+ - Add `undo [SNAPSHOT]`: restores the parts a snapshot saved (the latest by default), after saving the current state so the undo can be undone
70
+ - Add `snapshot` (save on request, `--scope`, `--message`), `snapshots` (list) and `gc --older-than DAYS`
71
+ - Every `done` result of `checkout`, `delete` and `reset` includes `undo` with the snapshot id; human output ends with `Undo with: gitflash undo ID`
72
+ - Add `hook claude`: a Claude Code `PreToolUse` hook that saves a snapshot before destructive git commands an agent runs itself (`reset`, `checkout -- .`, `restore`, `clean -f`, `branch -D`, `stash drop`, `rebase`, `worktree remove --force`, ...). Modes: `snapshot` (default), `ask`, `deny`. It never blocks a command because of its own errors
73
+ - Add `hook install [--scope local|project|user] [--mode ...]` to register the hook in Claude Code settings; it reports which sessions the file applies to, and the local scope uses the main checkout's root inside a worktree, as Claude Code does
74
+ - Add `hook status` to show whether the hook protects Claude Code sessions in the current directory
75
+ - JSON schema: new `undo` field, commands `undo`, `snapshots`, `snapshot`, `gc`, `hook`, error codes `unknown_snapshot`, `wrong_worktree`, `invalid_settings`
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GitFlash
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/gitflash.svg)](https://badge.fury.io/rb/gitflash)
3
+ [![GitHub version](https://badge.fury.io/gh/Kleanthismits%2Fgit_flash.svg)](https://badge.fury.io/gh/Kleanthismits%2Fgit_flash)
4
4
 
5
5
  ⚠️ **This project is still under development**: Use at your own risk!
6
6
 
@@ -38,9 +38,51 @@ Run `gitflash` inside a git repository to get a list with the available commands
38
38
  | `gitflash checkout [BRANCH]` | Check out a branch, or pick one from a list |
39
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
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 undo [SNAPSHOT]` | Restore a snapshot, the latest by default: deleted branches, moved branches, HEAD, uncommitted and untracked files, dropped stashes |
42
+ | `gitflash snapshot` | Save the current state on request (`--scope`, `--message`) |
43
+ | `gitflash snapshots` | List snapshots, newest first |
44
+ | `gitflash gc` | Delete snapshots older than 30 days (`--older-than DAYS`) |
45
+ | `gitflash hook install` | Protect plain git commands run by Claude Code (see below) |
46
+ | `gitflash hook status` | Show whether the hook protects Claude Code sessions in the current directory |
41
47
  | `gitflash schema` | Print the JSON Schema of the `--json` output |
42
48
  | `gitflash version` | Print the installed version (also `--version`, `-v`) |
43
49
 
50
+ ### Undo
51
+
52
+ Before every change it makes, gitflash saves a snapshot: branches, HEAD, tracked, staged and untracked files, and stash entries. Snapshots are plain git objects under `refs/gitflash/snapshots/`; the working tree is never touched while saving. Git's own `revert` and `reflog` cover committed work; snapshots also cover what git never recorded: uncommitted and untracked files, deleted branches (git deletes their reflog) and dropped stashes.
53
+
54
+ ```bash
55
+ gitflash delete old-feature --yes
56
+ ```
57
+
58
+ ```bash
59
+ gitflash undo --yes
60
+ ```
61
+
62
+ `gitflash undo` restores only what the snapshot saved and what differs, and saves the current state first, so an undo can itself be undone.
63
+
64
+ ### Protect plain git commands run by AI agents
65
+
66
+ Agents often run git directly. Claude Code's own checkpoints do not cover changes made by shell commands, so a `git reset --hard` or `git clean -fd` run by an agent cannot be rewound there. Install the gitflash hook once per repository:
67
+
68
+ ```bash
69
+ gitflash hook install
70
+ ```
71
+
72
+ Before Claude Code runs a command that can discard work git cannot restore (`reset`, `checkout -- .`, `restore`, `clean -f`, `branch -D`, `stash drop`, `rebase`, `worktree remove --force`, ...), the hook saves a snapshot and tells the agent how to undo it. Other commands pass through; the hook adds about 0.1 s to commands that do not mention git. `--mode ask` also asks you to approve such commands, `--mode deny` blocks them.
73
+
74
+ Where the hook goes, following Claude Code's own rules:
75
+
76
+ | Scope | File | Applies to |
77
+ | --- | --- | --- |
78
+ | `local` (default) | `.claude/settings.local.json` at the repository root (the main checkout's root inside a worktree) | Sessions anywhere in the repository and its worktrees |
79
+ | `project` | `.claude/settings.json` at the repository top level, to share with your team | Sessions started in that directory |
80
+ | `user` | `~/.claude/settings.json` | Every session |
81
+
82
+ Claude Code reloads settings files, so running sessions pick the hook up without a restart. Run `gitflash hook status` in the directory where you start Claude Code to check which files apply and whether the `gitflash` executable is found.
83
+
84
+ ### Scripts
85
+
44
86
  Without an argument, a command shows an interactive list. With arguments it runs directly, which also works in scripts.
45
87
 
46
88
  Options for every command:
@@ -76,6 +118,7 @@ With `--json`, every command prints one object in the same envelope, defined in
76
118
 
77
119
  - `status`: `done`, `planned` (`--dry-run`), `noop`, `cancelled`, `failed`, `confirmation_required` or `error`. `ok` is true for the first four.
78
120
  - `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`.
121
+ - `undo` names the snapshot saved before the change and the command that restores it (`gitflash undo ID`).
79
122
  - `error.code` is a stable identifier such as `unknown_branch`, `protected_branch` or `confirmation_required`.
80
123
 
81
124
  ```bash
data/bin/gitflash CHANGED
@@ -1,6 +1,16 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # Fast path for the agent hook, which runs before every shell command: when the command does
5
+ # not mention git there is nothing to do, so exit before loading the rest of gitflash.
6
+ if ARGV.first(2) == %w[hook claude]
7
+ input = $stdin.read
8
+ exit 0 unless input.include?('git')
9
+
10
+ require 'stringio'
11
+ $stdin = StringIO.new(input)
12
+ end
13
+
4
14
  require 'gitflash'
5
15
  require 'gitflash/cli'
6
16
 
@@ -46,3 +46,26 @@ reset:
46
46
  By default performs a mixed reset: your changes are kept as unstaged changes.
47
47
  Pass --soft to keep them staged, or --hard to discard them. A hard reset asks for
48
48
  confirmation; pass --yes to skip it, which is required without a terminal.
49
+
50
+ undo:
51
+ short: 'Restore a snapshot (the latest by default)'
52
+ long: |
53
+ Restores the parts of the repository a snapshot saved: deleted or moved branches,
54
+ HEAD, tracked, staged and untracked files, and dropped stash entries. Only parts that
55
+ differ are changed; branches and files created after the snapshot are left alone.
56
+
57
+ gitflash saves a snapshot before every change it makes, and the agent hook saves one
58
+ before destructive git commands. List them with `gitflash snapshots`.
59
+
60
+ The current state is saved as a new snapshot first, so an undo can itself be undone.
61
+ Asks for confirmation; pass --yes to skip it (required without a terminal) or
62
+ --dry-run to only show the plan.
63
+
64
+ snapshot:
65
+ short: 'Save the current state so it can be restored with gitflash undo'
66
+ long: |
67
+ Saves branches, HEAD, tracked, staged and untracked files (not ignored ones) and stash
68
+ entries as git objects under refs/gitflash/snapshots/. The working tree is not changed.
69
+ Untracked files larger than 50 MB are skipped and listed.
70
+
71
+ Use --scope to save only some parts, for example --scope worktree.
@@ -29,6 +29,12 @@ module Gitflash
29
29
  )
30
30
  end
31
31
 
32
+ # { branch name => sha } from `git for-each-ref --format=TIPS_FORMAT refs/heads/`
33
+ def self.parse_tips(output)
34
+ output.to_s.each_line(chomp: true).to_h { |line| line.split(' ', 2) }
35
+ .transform_keys { |ref| ref.delete_prefix(Repo::HEADS) }
36
+ end
37
+
32
38
  # The record's fields; the full ref name is shortened by removing refs/heads/ only, because
33
39
  # `refname:short` gives another name when a tag has the same name.
34
40
  def self.fields(line)
@@ -50,4 +56,5 @@ module Gitflash
50
56
  super.merge(last_commit_at: last_commit_at.iso8601)
51
57
  end
52
58
  end
59
+ Branch::TIPS_FORMAT = '%(refname) %(objectname)'
53
60
  end
data/lib/gitflash/cli.rb CHANGED
@@ -5,6 +5,7 @@ require 'thor'
5
5
  module Gitflash
6
6
  class Cli < Thor
7
7
  extend Configuration::Descriptions
8
+ include CommandRunner
8
9
 
9
10
  SCHEMA_PATH = File.expand_path('../../schema/v1.json', __dir__)
10
11
 
@@ -25,13 +26,13 @@ module Gitflash
25
26
  option :gone, type: :boolean, desc: 'Branches whose upstream branch was deleted'
26
27
  option :stale, type: :numeric, banner: 'DAYS', desc: 'Branches without commits for DAYS days'
27
28
  def branches
28
- run_command(Commands::Branches)
29
+ run_command('branches', Commands::Branches)
29
30
  end
30
31
 
31
32
  desc 'checkout [BRANCH]', descriptions.checkout.short
32
33
  long_desc descriptions.checkout.long
33
34
  def checkout(branch = nil)
34
- run_command(Commands::Checkout, *[branch].compact)
35
+ run_command('checkout', Commands::Checkout, *[branch].compact)
35
36
  end
36
37
 
37
38
  desc 'delete [BRANCH...]', descriptions.delete.short
@@ -39,7 +40,7 @@ module Gitflash
39
40
  option :force, type: :boolean, default: false,
40
41
  desc: 'Delete branches even if they have unmerged changes'
41
42
  def delete(*branches)
42
- run_command(Commands::Delete, *branches)
43
+ run_command('delete', Commands::Delete, *branches)
43
44
  end
44
45
 
45
46
  desc 'reset [COMMIT]', descriptions.reset.short
@@ -47,9 +48,39 @@ module Gitflash
47
48
  option :hard, type: :boolean, default: false, desc: 'Discard all current changes'
48
49
  option :soft, type: :boolean, default: false, desc: 'Keep changes staged'
49
50
  def reset(commit = nil)
50
- run_command(Commands::Reset, *[commit].compact)
51
+ run_command('reset', Commands::Reset, *[commit].compact)
51
52
  end
52
53
 
54
+ desc 'undo [SNAPSHOT]', descriptions.undo.short
55
+ long_desc descriptions.undo.long
56
+ def undo(snapshot = nil)
57
+ run_command('undo', Commands::Undo, *[snapshot].compact)
58
+ end
59
+
60
+ desc 'snapshots', 'List snapshots, newest first'
61
+ def snapshots
62
+ run_command('snapshots', Commands::SnapshotList)
63
+ end
64
+
65
+ desc 'snapshot', descriptions.snapshot.short
66
+ long_desc descriptions.snapshot.long
67
+ option :message, type: :string, desc: 'Why the snapshot was taken'
68
+ option :scope, type: :array, banner: 'PARTS',
69
+ desc: 'Parts to save: branches head worktree stashes (default: all)'
70
+ def snapshot
71
+ run_command('snapshot', Commands::SnapshotCreate)
72
+ end
73
+
74
+ desc 'gc', 'Delete old snapshots'
75
+ option :older_than, type: :numeric, banner: 'DAYS',
76
+ desc: 'Delete snapshots older than DAYS days (default 30)'
77
+ def gc
78
+ run_command('gc', Commands::Gc)
79
+ end
80
+
81
+ desc 'hook SUBCOMMAND', 'Agent hooks: claude (run the hook), install (register it)'
82
+ subcommand 'hook', HookCli
83
+
53
84
  desc 'schema', 'Print the JSON Schema of the --json output'
54
85
  def schema
55
86
  puts File.read(SCHEMA_PATH)
@@ -61,22 +92,5 @@ module Gitflash
61
92
  def version
62
93
  puts VERSION
63
94
  end
64
-
65
- private
66
-
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)
76
- end
77
-
78
- def build_ui(command)
79
- Ui.new(command: command, json: options[:json], yes: options[:yes], dry_run: options[:dry_run])
80
- end
81
95
  end
82
96
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Runs a command class with the parsed Thor options and turns errors into output and exit codes
5
+ module CommandRunner
6
+ private
7
+
8
+ def run_command(name, command_class, *, repo_required: true)
9
+ ui = build_ui(name)
10
+ repo = Repo.new
11
+ repo.ensure_work_tree! if repo_required
12
+ status = command_class.new(repo: repo, ui: ui, options: options).call(*)
13
+ exit(status) unless status.zero?
14
+ rescue Error => e
15
+ ui.error(e)
16
+ exit(e.exit_code)
17
+ end
18
+
19
+ def build_ui(name)
20
+ Ui.new(command: name, json: options[:json], yes: options[:yes], dry_run: options[:dry_run])
21
+ end
22
+ end
23
+ end
@@ -26,6 +26,15 @@ module Gitflash
26
26
  raise Error.new("git #{command} failed:\n#{result.output}", code: 'git_failed')
27
27
  end
28
28
 
29
+ def snapshots
30
+ @snapshots ||= Gitflash::Snapshots.new
31
+ end
32
+
33
+ # Saves the state a change is about to modify, so `gitflash undo` can restore it
34
+ def take_snapshot(reason, scope:, branches: nil)
35
+ snapshots.create(reason: reason, scope: scope, branches: branches)
36
+ end
37
+
29
38
  def planned(plan, text)
30
39
  ui.report(status: 'planned', plan: plan, text: text)
31
40
  end
@@ -32,11 +32,12 @@ module Gitflash
32
32
  plan = { branch: name }
33
33
  return planned(plan, "Would check out '#{name}'") if ui.dry_run?
34
34
 
35
+ snapshot = take_snapshot("gitflash checkout #{name}", scope: %w[head])
35
36
  result = repo.checkout(name)
36
37
  git_error!('checkout', result) unless result.success?
37
38
 
38
39
  ui.report(status: 'done', plan: plan, result: { branch: name, previous_branch: current },
39
- text: "Switched to branch '#{name}'")
40
+ undo: snapshot, text: "Switched to branch '#{name}'")
40
41
  end
41
42
 
42
43
  def noop(branch, text)
@@ -56,8 +56,10 @@ module Gitflash
56
56
  return planned(plan, "Would delete:\n#{bullets(names)}") if ui.dry_run?
57
57
  return cancelled(plan) unless ui.confirm?(summary(names), plan: plan)
58
58
 
59
+ snapshot = take_snapshot("gitflash delete #{names.join(' ')}",
60
+ scope: %w[branches], branches: names)
59
61
  outcomes = names.map { |name| [name, repo.delete_branch(name, force: force?)] }
60
- report_results(plan, outcomes, shas)
62
+ report_results(plan, outcomes, shas, snapshot)
61
63
  end
62
64
 
63
65
  def summary(names)
@@ -74,14 +76,15 @@ module Gitflash
74
76
  nil
75
77
  end
76
78
 
77
- def report_results(plan, outcomes, shas)
79
+ def report_results(plan, outcomes, shas, snapshot)
78
80
  deleted, failed = outcomes.partition { |_name, result| result.success? }
79
81
  result = {
80
82
  deleted: deleted.map { |name, _result| { branch: name, sha: shas[name] } },
81
83
  failed: failed.map { |name, result| { branch: name, error: result.output } }
82
84
  }
83
85
  ui.report(status: failed.empty? ? 'done' : 'failed', plan: plan, result: result,
84
- error: failure(failed, outcomes), text: results_text(result))
86
+ error: failure(failed, outcomes), text: results_text(result),
87
+ undo: (snapshot if deleted.any?))
85
88
  end
86
89
 
87
90
  def failure(failed, outcomes)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Deletes snapshots older than --older-than DAYS (default 30). Git frees their objects at
6
+ # its next garbage collection.
7
+ class Gc < Base
8
+ DEFAULT_DAYS = 30
9
+
10
+ def call
11
+ days = options[:older_than] || DEFAULT_DAYS
12
+ old = older_than(days)
13
+ plan = { older_than_days: days, snapshots: old.map(&:id) }
14
+ return nothing(plan) if old.empty?
15
+
16
+ delete(plan, "#{old.size} snapshots older than #{days} days")
17
+ end
18
+
19
+ private
20
+
21
+ def delete(plan, text)
22
+ return planned(plan, "Would delete #{text}") if ui.dry_run?
23
+ return cancelled(plan) unless ui.confirm?("You are about to delete #{text}", plan: plan)
24
+
25
+ plan[:snapshots].each { |id| snapshots.delete(id) }
26
+ ui.report(status: 'done', plan: plan, result: { deleted: plan[:snapshots] },
27
+ text: "Deleted #{text}")
28
+ end
29
+
30
+ def older_than(days)
31
+ cutoff = Time.now - (days * 86_400)
32
+ snapshots.list.select { |snapshot| Time.iso8601(snapshot.created_at) < cutoff }
33
+ end
34
+
35
+ def nothing(plan)
36
+ ui.report(status: 'noop', plan: plan, result: { deleted: [] },
37
+ text: "No snapshots older than #{plan[:older_than_days]} days")
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Gitflash
6
+ module Commands
7
+ # Registers `gitflash hook claude` as a Claude Code PreToolUse hook for Bash commands.
8
+ # Merges into the settings file and keeps everything else in it.
9
+ class HookInstall < Base
10
+ SCOPES = Hook::Settings::SCOPES
11
+
12
+ def call
13
+ plan = build_plan
14
+ data = settings.read(plan[:file])
15
+ entry = settings.entry(data)
16
+ return already_installed(plan) if entry && entry['command'] == plan[:command]
17
+ return planned(plan, "Would add the gitflash hook to #{plan[:file]}") if ui.dry_run?
18
+
19
+ install(data, entry, plan)
20
+ end
21
+
22
+ private
23
+
24
+ def build_plan
25
+ scope = choice(:scope, SCOPES, 'local')
26
+ { scope: scope, file: settings.path(scope), command: hook_command,
27
+ applies_to: settings.applies_to(scope) }
28
+ end
29
+
30
+ def settings
31
+ @settings ||= Hook::Settings.new(options[:scope] == 'user' ? nil : repo)
32
+ end
33
+
34
+ def install(data, entry, plan)
35
+ entry ? entry['command'] = plan[:command] : add_entry(data, plan[:command])
36
+ FileUtils.mkdir_p(File.dirname(plan[:file]))
37
+ File.write(plan[:file], "#{JSON.pretty_generate(data)}\n")
38
+ ui.report(status: 'done', plan: plan, result: plan,
39
+ text: "Installed the gitflash hook in #{plan[:file]}.\n" \
40
+ "It applies to #{plan[:applies_to]}, including running ones.")
41
+ end
42
+
43
+ def choice(name, allowed, default)
44
+ value = options[name] || default
45
+ return value if allowed.include?(value)
46
+
47
+ usage_error!('invalid_options', "Unknown #{name} '#{value}' (use #{allowed.join(', ')})")
48
+ end
49
+
50
+ def hook_command
51
+ mode = choice(:mode, Hook::Claude::MODES, 'snapshot')
52
+ mode == 'snapshot' ? 'gitflash hook claude' : "gitflash hook claude --mode #{mode}"
53
+ end
54
+
55
+ def already_installed(plan)
56
+ ui.report(status: 'noop', plan: plan, result: plan,
57
+ text: "Already installed in #{plan[:file]}.\nIt applies to #{plan[:applies_to]}.")
58
+ end
59
+
60
+ def add_entry(data, command)
61
+ hooks = (data['hooks'] ||= {})
62
+ (hooks['PreToolUse'] ||= []) << {
63
+ 'matcher' => 'Bash',
64
+ 'hooks' => [{ 'type' => 'command', 'command' => command, 'timeout' => 30 }]
65
+ }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Shows whether the gitflash hook protects Claude Code sessions in the current directory:
6
+ # which settings files hold it, which sessions each file applies to, and whether the
7
+ # gitflash executable the hook runs can be found.
8
+ class HookStatus < Base
9
+ def call
10
+ settings = Hook::Settings.new(inside_repo? ? repo : nil)
11
+ files = settings.scopes.map { |scope| file_status(settings, scope) }
12
+ executable = find_executable
13
+ active = executable && files.any? { |file| file[:installed] }
14
+ result = { active: active ? true : false, executable: executable, files: files }
15
+ ui.report(status: 'done', result: result, text: text(result))
16
+ end
17
+
18
+ private
19
+
20
+ def inside_repo?
21
+ repo.ensure_work_tree!
22
+ true
23
+ rescue Error
24
+ false
25
+ end
26
+
27
+ def file_status(settings, scope)
28
+ path = settings.path(scope)
29
+ entry = settings.entry(settings.read(path))
30
+ { scope: scope, file: path, installed: !entry.nil?, command: entry&.fetch('command', nil),
31
+ applies_to: settings.applies_to(scope) }
32
+ end
33
+
34
+ def find_executable
35
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).map { |dir| File.join(dir, 'gitflash') }
36
+ .find { |path| File.file?(path) && File.executable?(path) }
37
+ end
38
+
39
+ def text(result)
40
+ lines = [headline(result)]
41
+ result[:files].each do |file|
42
+ state = file[:installed] ? "installed (#{file[:command]})" : 'not installed'
43
+ lines << " #{file[:scope].ljust(8)} #{file[:file]}: #{state}"
44
+ lines << " applies to #{file[:applies_to]}" if file[:installed]
45
+ end
46
+ lines << " gitflash executable: #{result[:executable] || 'not found on PATH'}"
47
+ lines.join("\n")
48
+ end
49
+
50
+ def headline(result)
51
+ return 'gitflash hook: active' if result[:active]
52
+
53
+ reason = if result[:executable]
54
+ 'run `gitflash hook install`'
55
+ else
56
+ 'the gitflash executable is not on PATH'
57
+ end
58
+ "gitflash hook: not active (#{reason})"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -48,11 +48,17 @@ module Gitflash
48
48
 
49
49
  def execute(plan, text)
50
50
  previous = repo.resolve_commit('HEAD')
51
+ snapshot = snapshot_before(plan)
51
52
  result = repo.reset(plan[:commit], mode: plan[:mode])
52
53
  git_error!('reset', result) unless result.success?
53
54
 
54
55
  ui.report(status: 'done', plan: plan, result: plan.merge(previous_commit: previous),
55
- text: text.capitalize)
56
+ undo: snapshot, text: text.capitalize)
57
+ end
58
+
59
+ def snapshot_before(plan)
60
+ take_snapshot("gitflash reset --#{plan[:mode]} #{plan[:commit][0, 7]}",
61
+ scope: %w[branches head worktree], branches: [repo.current_branch].compact)
56
62
  end
57
63
 
58
64
  def confirmed?(plan)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Saves the current state on request, for example before a risky step
6
+ class SnapshotCreate < Base
7
+ def call
8
+ snapshot = take_snapshot(options[:message] || 'gitflash snapshot', scope: scope)
9
+ ui.report(status: 'done', result: { snapshot: snapshot.to_h }, undo: snapshot,
10
+ text: text(snapshot))
11
+ end
12
+
13
+ private
14
+
15
+ def scope
16
+ scope = options[:scope] || Snapshots::SCOPES
17
+ invalid = scope - Snapshots::SCOPES
18
+ usage_error!('invalid_options', "Unknown scope: #{invalid.join(', ')}") if invalid.any?
19
+ scope
20
+ end
21
+
22
+ def text(snapshot)
23
+ text = "Saved snapshot #{snapshot.id}"
24
+ return text if snapshot.skipped_files.empty?
25
+
26
+ "#{text}\nSkipped large untracked files: #{snapshot.skipped_files.join(', ')}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Lists snapshots, newest first
6
+ class SnapshotList < Base
7
+ def call
8
+ list = snapshots.list
9
+ text = list.empty? ? 'No snapshots' : list.map { |snapshot| row(snapshot) }.join("\n")
10
+ ui.report(status: 'done', result: { snapshots: list.map(&:to_h) }, text: text)
11
+ end
12
+
13
+ private
14
+
15
+ def row(snapshot)
16
+ "#{snapshot.id} #{snapshot.scope.join(',').ljust(28)} #{snapshot.reason}"
17
+ end
18
+ end
19
+ end
20
+ end