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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +44 -1
- data/bin/gitflash +10 -0
- data/command_descriptions.yml +23 -0
- data/lib/gitflash/branch.rb +7 -0
- data/lib/gitflash/cli.rb +35 -21
- data/lib/gitflash/command_runner.rb +23 -0
- data/lib/gitflash/commands/base.rb +9 -0
- data/lib/gitflash/commands/checkout.rb +2 -1
- data/lib/gitflash/commands/delete.rb +6 -3
- data/lib/gitflash/commands/gc.rb +41 -0
- data/lib/gitflash/commands/hook_install.rb +69 -0
- data/lib/gitflash/commands/hook_status.rb +62 -0
- data/lib/gitflash/commands/reset.rb +7 -1
- data/lib/gitflash/commands/snapshot_create.rb +30 -0
- data/lib/gitflash/commands/snapshot_list.rb +20 -0
- data/lib/gitflash/commands/undo.rb +99 -0
- data/lib/gitflash/git/bash_command.rb +5 -4
- data/lib/gitflash/hook/claude.rb +106 -0
- data/lib/gitflash/hook/command_parser.rb +101 -0
- data/lib/gitflash/hook/rules.rb +108 -0
- data/lib/gitflash/hook/settings.rb +60 -0
- data/lib/gitflash/hook_cli.rb +69 -0
- data/lib/gitflash/repo.rb +10 -0
- data/lib/gitflash/restore.rb +140 -0
- data/lib/gitflash/snapshot.rb +47 -0
- data/lib/gitflash/snapshots.rb +70 -0
- data/lib/gitflash/state_capture.rb +129 -0
- data/lib/gitflash/ui.rb +7 -5
- data/lib/gitflash/version.rb +1 -1
- data/schema/v1.json +804 -69
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a9dbc4a8ff348766f4e0a7911d451f372a4ab4b355e55d9f260d453a668948f
|
|
4
|
+
data.tar.gz: 687787d41cf614c244512ac27a105bc34cf1d75a169d6eec14f56c80a3ab1c88
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
[](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
|
|
data/command_descriptions.yml
CHANGED
|
@@ -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.
|
data/lib/gitflash/branch.rb
CHANGED
|
@@ -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
|