gitflash 0.7.0 → 0.8.0.beta1

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: 18bd58392a7c1910adab093664cb626d39cb5577a1eb48e7c773c0b93949a334
4
- data.tar.gz: 6512efce4e81068a855a7732e9fead33d7a45eb2a1b0cf5313fc179d4e0871c8
3
+ metadata.gz: 1c0cf7d7078454db30329d4dcc8308117ef79d36e3fc591e9d0dc430c039d5a6
4
+ data.tar.gz: 955eda7164005c0edf87de99c2d8e2eba3871d79bf5fd02b2c41df6c80ca6c0b
5
5
  SHA512:
6
- metadata.gz: 62038936bb314c4178b4bb256a1447888fa5dd00711bc3923e3ca1ff7e4d23880342c24fd280073b130b93dc210b79e221fe4fa4e853b2993d059ba66c7d133b
7
- data.tar.gz: 03ce280cac82382e979fd3c258d5b571acb8e4a4dda1a4aa552e04b7bf6cc7d770ce6831199a8abc49d878a8e094c1fd41fc5637bfb4d4dc97ba2ce5b1818811
6
+ metadata.gz: 4ee95cc534f57300fadd33f23284b1d52e9711b38e1cf52d54143033e85df69f4a6bcf62a1b475131cc50093a7ae244d36aed153f548810cd23b756fe9af1a04
7
+ data.tar.gz: '08cbdd661d6106437cd2efec5e0c6fe007c25703713fa0244499228f0026374d6aa4231afc2d2309a8f24cb624b44a174e07e933d86916fac546813e9a3adfa7'
data/CHANGELOG.md CHANGED
@@ -155,3 +155,35 @@ Upgrading from 0.6.0:
155
155
 
156
156
  - Run `gitflash hook install` again, so the hook also marks branches created with plain git (`PostToolUse`). It keeps the installed mode
157
157
  - Check scripts and agent instructions against the behaviour changes listed under 0.7.0.beta3: `--confirm TOKEN` for `gc` and for removing worktrees with ignored files, `delete` and `clean` protecting `develop`, `trunk` and `release/*`, `mark` defaulting to `human` in a terminal, and usage errors exiting with 2
158
+
159
+ ## [0.8.0.beta1] - 2026-11-02
160
+
161
+ First beta of the agent integrations release: an Agent Skill, an MCP server that asks the user to approve destructive changes, and a Claude Code plugin that installs the hook, the server and the skill together. Install with `gem install gitflash --pre`; the plugin needs this gem. Needs git 2.36 or newer.
162
+
163
+ Behaviour changes to check in scripts and agent instructions:
164
+
165
+ - `clean` and `wt clean` keep a branch nobody has committed to since it was created, also with `--agent` and `--force`; `--stale` removes it later
166
+ - `--stale` counts the latest move of a branch, not only its latest commit
167
+ - `undo` in the result can name an earlier snapshot, marked `"reused": true`, when nothing changed since it was taken
168
+
169
+ The changes:
170
+
171
+ - Add the gitflash Agent Skill (`skills/gitflash/SKILL.md`, shipped in the gem): when to use gitflash instead of plain git, the `--json` envelope, the plan then `--yes` flow, `--confirm` tokens, the commands and how to recover lost work with `undo`
172
+ - Add `skill install`: copies the skill to `~/.claude/skills/gitflash/` (default, `--scope user`), to `.claude/skills/gitflash/` in the repository (`--scope project`) or to `DIR/gitflash/` (`--path DIR`). Replacing a file that differs from the shipped skill needs `--confirm TOKEN`; the project scope refuses symbolic links
173
+ - Add `skill show`, which prints the skill
174
+ - The skill and the MCP server's instructions tell agents to prefer the MCP tools for changes (they can ask the user), to show the list and wait for an OK before a rule-based cleanup (`clean`, `wt clean`), and not to save their own snapshot before plain git commands when the gitflash hook is installed
175
+ - JSON schema: new command `skill install` with the `skill_install` plan and result
176
+ - Add `mcp`: an MCP server on stdio (official `mcp` gem, loaded only by this command) for the repository in the current directory. Read-only tools `list_branches`, `list_worktrees`, `list_pickable_commits`, `list_snapshots`; tools that lose nothing `add_worktree`, `cherry_pick_commits`, `cherry_pick_control`, `mark_branches`, `save_snapshot`; destructive tools `delete_branches`, `clean_branches`, `reset_branch`, `remove_worktrees`, `move_worktree`, `undo`. Every tool answers with the JSON envelope. A destructive tool's first call returns the plan and `error.confirm_token`; a second call with `confirm_token` (valid 5 minutes, bound to the arguments and the plan) makes the change
177
+ - `mcp` asks the user to approve a destructive tool's plan through the client when it supports elicitation, runs the change when they approve and answers `cancelled` when they decline. Without an answer within 2 minutes, an error, or with `--no-elicitation`, the token flow is used
178
+ - `mcp` asks the user on 2026-07-28 connections too (Claude Code makes them), where the server may not send requests: the form comes back as an `input_required` result, and the client's retry carries the answer and a signed state bound to the plan. Before, the question always failed there and the token flow was used
179
+ - `mcp` writes to stderr why it did not ask the user (no elicitation capability, an error, no answer in time, `--no-elicitation`); MCP clients keep it in their logs
180
+ - `mcp` tool `plan_change`: the plan and `confirm_token` of a destructive tool from a read-only call, so a client that asks before every destructive call asks only once
181
+ - When nothing changed since the latest snapshot, `undo` in the result names that snapshot with `"reused": true`, and text output says it is an earlier one. Before, its older id looked like a missing snapshot
182
+ - New runtime dependency: `mcp` (~> 1.8)
183
+ - Fix: the Claude Code hook marks a branch created from an older commit as agent work. It used the commit's date to decide whether the branch was created a moment ago, so only branches made from a commit of the last two minutes were marked; it now uses the time of the branch's reflog entry. A branch deleted and created again at the same commit is also told apart from the old one now
184
+ - `clean` and `wt clean` keep a branch nobody has committed to since it was created, even though it is reachable from the default branch (`merged`) or marked as agent work (`--agent`): someone may be about to work on it. Before, a branch just cut from main was deleted as merged. It is listed in `plan.skipped` with the reason, and `--stale` removes it once it has been unused that long; `--force` does not change this. gitflash reads the branch's reflog (one git call); a branch without a reflog is judged as before
185
+ - `--stale` (in `clean`, `wt clean` and `branches`) counts the latest move of the branch (its newest reflog entry) as activity, not only its latest commit, so a branch cut today from an old commit is not stale
186
+ - Fix: the Claude Code hook reads heredocs. Their lines were read as commands, so a heredoc that only quoted a git command (a commit message passed with `git commit -F - <<'EOF'`) caused an extra snapshot, and an apostrophe or unbalanced quote in one hid the commands after it, which then ran without a snapshot. Now a quoted heredoc runs nothing, an unquoted one runs only its `$(...)` and backticks, and a shell reading one as its script (`bash <<'EOF'`) has the script parsed
187
+ - JSON schema: branches carry `unused` and `last_activity_at` (from the reflog, null without one)
188
+ - `mcp` starts outside a git repository too; its tools then answer with `not_a_repository`
189
+ - Claude Code plugin (`.claude-plugin/`): the hook (snapshot mode), the MCP server and the skill in one install with `/plugin marketplace add Kleanthismits/git_flash` and `/plugin install gitflash@gitflash`. It needs the gitflash gem
data/README.md CHANGED
@@ -1,80 +1,125 @@
1
- # GitFlash
1
+ # gitflash
2
2
 
3
- [![GitHub version](https://badge.fury.io/gh/Kleanthismits%2Fgit_flash.svg)](https://badge.fury.io/gh/Kleanthismits%2Fgit_flash)
3
+ [![Gem Version](https://img.shields.io/gem/v/gitflash)](https://rubygems.org/gems/gitflash)
4
+ [![RSpec](https://github.com/Kleanthismits/git_flash/actions/workflows/rspec.yml/badge.svg)](https://github.com/Kleanthismits/git_flash/actions/workflows/rspec.yml)
4
5
 
5
- ⚠️ **This project is still under development**: Use at your own risk!
6
+ **A safety net and cleanup tool for git repositories that AI coding agents work in.**
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
+ gitflash works on plain git, with nothing new to adopt. Every command can be run by a person in a terminal or by an agent through one stable JSON contract. (Not to be confused with git-flow, the branching model.)
8
9
 
9
- See [docs/ROADMAP.md](docs/ROADMAP.md) for where the project is going.
10
+ > **Status: pre-1.0.** Command flags may still change between minor versions. The JSON contract ([schema v1](https://github.com/Kleanthismits/git_flash/blob/main/schema/v1.json)) is versioned and stable. See the [roadmap](https://github.com/Kleanthismits/git_flash/blob/main/docs/ROADMAP.md) for where the project is going.
10
11
 
11
- ## Installation
12
+ ## Why
13
+
14
+ An agent runs `git reset --hard` or `git clean -fd` from the shell. Claude Code's checkpoints do not track shell commands, and git never recorded the uncommitted and untracked files, so that work is gone. With gitflash:
15
+
16
+ ```bash
17
+ gitflash hook install # once per repository
12
18
 
13
- Add this line to your application's Gemfile:
19
+ # Later, Claude Code runs a destructive command such as `git reset --hard HEAD~3`.
20
+ # The hook saves a snapshot first and tells the agent how to undo it.
14
21
 
15
- ```ruby
16
- gem 'gitflash'
22
+ gitflash undo --yes # HEAD, branches, uncommitted and untracked files come back
17
23
  ```
18
24
 
19
- And then execute:
25
+ To see it happen without an agent, run [`demo/agent-undo.sh`](https://github.com/Kleanthismits/git_flash/blob/main/demo/agent-undo.sh) from a checkout. It works in a throwaway repository and feeds the hook the same input Claude Code sends.
26
+
27
+ <details>
28
+ <summary>See it run</summary>
29
+
30
+ ```text
31
+ == A repository with uncommitted work
32
+ $ git status --short
33
+ M app.rb
34
+ ?? notes.txt
35
+
36
+ == The agent is about to run: git reset --hard && git clean -fd
37
+ $ <Claude Code hook input> | gitflash hook claude
38
+ {
39
+ "hookSpecificOutput": {
40
+ "hookEventName": "PreToolUse",
41
+ "additionalContext": "gitflash saved snapshot 20261102T094036000992-f633 before `git reset --hard && git clean -fd`. If this discards work that was still needed, restore it with `gitflash undo 20261102T094036000992-f633` (list snapshots with `gitflash snapshots`)."
42
+ }
43
+ }
44
+
45
+ == The command runs. Git alone cannot get this work back
46
+ $ git reset --hard && git clean -fd
47
+ HEAD is now at 1313e78 Initial commit
48
+ Removing notes.txt
49
+ $ git status --short
50
+ $ cat app.rb
51
+ puts "hello"
52
+
53
+ == gitflash undo restores the snapshot the hook saved
54
+ $ gitflash undo --yes
55
+ Restored snapshot 20261102T094036000992-f633:
56
+ * restore tracked, staged and untracked files
57
+ Undo with: gitflash undo 20261102T094037415717-1342
58
+ $ git status --short
59
+ M app.rb
60
+ ?? notes.txt
61
+ $ cat app.rb notes.txt
62
+ puts "hello, world"
63
+ API_URL=http://localhost:3000
64
+
65
+ Uncommitted edit and untracked file are back.
66
+ ```
67
+
68
+ </details>
69
+
70
+ gitflash also cleans up after parallel agent work: it knows which branches and worktrees agents created, and removes the merged, gone and stale ones with a snapshot first.
20
71
 
21
- $ bundle install
72
+ ## Installation
22
73
 
23
- Or install it yourself as:
74
+ ```bash
75
+ gem install gitflash
76
+ ```
24
77
 
25
- $ gem install gitflash
78
+ Requires Ruby 3.3+ and git 2.36+ (the worktree commands read `git worktree list -z`).
26
79
 
27
80
  Prerelease versions (named like `0.7.0.beta3`) are only installed when you ask for them: `gem install gitflash --pre`, or the exact version in a Gemfile.
28
81
 
29
- ## Requirements
82
+ ## Quick start for agents
30
83
 
31
- This gem requires Ruby 3.3+ and git 2.36+ (the worktree commands read `git worktree list -z`).
84
+ Pick one. Details for each are in [Agent integrations](https://github.com/Kleanthismits/git_flash/blob/main/docs/agent-integrations.md).
32
85
 
33
- ## Usage
86
+ | Way | Command | You get |
87
+ | --- | --- | --- |
88
+ | Claude Code plugin | `/plugin marketplace add Kleanthismits/git_flash`, then `/plugin install gitflash@gitflash` | Hook, MCP server and skill in one install (gem 0.8.0 or newer; for now the 0.8.0.beta1 prerelease: `gem install gitflash --pre`) |
89
+ | Hook and skill | `gitflash hook install` and `gitflash skill install` | Snapshots before destructive plain git commands; the agent learns to prefer gitflash |
90
+ | MCP server | `claude mcp add gitflash -- gitflash mcp` | gitflash as MCP tools, with your approval before destructive changes |
34
91
 
35
- Run `gitflash` inside a git repository to get a list with the available commands and their description.
92
+ Do not combine the plugin with `gitflash hook install`, or the hook runs twice.
36
93
 
37
- | Command | Description |
94
+ ## Commands
95
+
96
+ Run `gitflash` inside a git repository for the list, and `gitflash help <command>` for the flags of one command. Without arguments, most commands show an interactive list.
97
+
98
+ | Command | What it does |
38
99
  | --- | --- |
39
- | `gitflash branches` | List local branches with last commit, upstream status and merge status. Filter with `--merged`, `--gone` or `--stale DAYS` |
100
+ | `gitflash branches` | List local branches with last commit, upstream and merge status (`--merged`, `--gone`, `--stale DAYS`) |
40
101
  | `gitflash checkout [BRANCH]` | Check out a branch, or pick one from a list |
41
- | `gitflash delete [BRANCH...]` | Delete branches, or pick them from a list. The current and default branch, `main`, `master`, `develop`, `development`, `trunk` and `release/*` are protected. Unmerged branches are kept unless you pass `--force` |
42
- | `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 |
43
- | `gitflash mark [BRANCH...]` | Mark branches as agent or human work (default: human when stdin is a terminal, agent otherwise; a script run from a terminal should pass `--owner agent`), or `--clear` the mark. Without a branch it marks the current one. Use it for branches made with plain git so `clean --agent` can find them |
44
- | `gitflash clean` | Delete merged branches and branches whose upstream is gone, with a snapshot first. `--stale [DAYS]` and `--agent` add stale and agent-created branches; unmerged ones are kept unless `--force`. Protected: current, default, `main`, `master`, `develop`, `development`, `trunk`, `release/*`, `protected` in `.gitflash.yml`, branches checked out in a worktree |
45
- | `gitflash worktree` (`wt`) `list`, `add`, `remove`, `clean`, `prune`, `lock`, `unlock`, `move` | Worktrees for parallel agent work, with absolute paths in `--json` (see below) |
46
- | `gitflash pick [SOURCE] [SHA...]` | Cherry-pick commits from another branch (pick SOURCE from a list when you omit it): `--list` shows what is missing here, `--continue`, `--skip` and `--abort` handle conflicts |
47
- | `gitflash undo [SNAPSHOT]` | Restore a snapshot, by default the latest one taken in this worktree: deleted branches, moved branches, HEAD, uncommitted and untracked files, dropped stashes |
102
+ | `gitflash delete [BRANCH...]` | Delete branches; protected and unmerged branches are kept unless `--force` |
103
+ | `gitflash reset [COMMIT]` | Reset to a commit (mixed by default, `--soft`, `--hard` after confirmation) |
104
+ | `gitflash clean` | Delete merged branches and branches whose upstream is gone (`--stale [DAYS]`, `--agent` for agent-created ones) |
105
+ | `gitflash mark [BRANCH...]` | Mark branches as agent or human work, so `clean --agent` finds branches made with plain git |
106
+ | `gitflash worktree` (`wt`) | `list`, `add`, `remove`, `clean`, `prune`, `lock`, `unlock`, `move` worktrees for parallel agent work |
107
+ | `gitflash pick [SOURCE] [SHA...]` | Cherry-pick commits from another branch (`--list`, `--continue`, `--skip`, `--abort`) |
108
+ | `gitflash undo [SNAPSHOT]` | Restore a snapshot, by default the latest one taken in this worktree |
48
109
  | `gitflash snapshot` | Save the current state on request (`--scope`, `--message`) |
49
110
  | `gitflash snapshots` | List snapshots, newest first |
50
- | `gitflash gc` | Delete snapshots older than 30 days (`--older-than DAYS`, at least 1) |
51
- | `gitflash hook install` | Protect plain git commands run by Claude Code and mark the branches it creates (see below) |
52
- | `gitflash hook status` | Show whether the hook protects Claude Code sessions in the current directory |
111
+ | `gitflash gc` | Delete snapshots older than 30 days (`--older-than DAYS`) |
112
+ | `gitflash hook install`, `hook status` | Protect plain git commands run by Claude Code, and check where the hook applies |
113
+ | `gitflash skill install`, `skill show` | Install or print the gitflash Agent Skill |
114
+ | `gitflash mcp` | Run an MCP server for the current repository on stdio |
53
115
  | `gitflash schema` | Print the JSON Schema of the `--json` output |
54
116
  | `gitflash version` | Print the installed version (also `--version`, `-v`) |
55
117
 
56
- ### Parallel agent work
57
-
58
- `gitflash wt add feature` creates a worktree (default `../<repo>.worktrees/feature`) and marks a new branch as agent work. For a branch made with plain git, run `gitflash mark` on it (no argument marks the current branch). `gitflash wt list --json` gives every worktree's absolute path, branch, dirty state, ahead/behind, merge status, lock state and owner, so an agent never has to switch directories.
59
-
60
- `gitflash wt remove` and `gitflash wt clean` save a snapshot first, and `gitflash undo` adds the worktree back with its branch and uncommitted files. Ignored files (`.env`, `node_modules`) are not saved: the plan lists them before they are deleted, and removing a worktree that has some needs `--confirm TOKEN` (the token the refusal names) instead of `--yes`. A worktree with an untracked file over 50 MB is not removed.
61
-
62
- `gitflash pick other-branch --list` lists the commits missing here (a commit whose patch is already here is marked `=`). `gitflash pick other-branch SHA... --yes` applies them oldest first with `-x`. On a conflict it stops and lists the files; resolve them and run `gitflash pick --continue`, or `--skip` / `--abort`.
63
-
64
- Settings live in `.gitflash.yml` in the repository and `~/.config/gitflash.yml` (the repository file wins, except for `worktree_dir`, where your own file wins; a repository's `worktree_dir` must be a relative path that stays next to the repository):
65
-
66
- ```yaml
67
- protected: ["release/*", "hotfix/{a,b}"] # branch patterns clean and delete never touch; both files add up
68
- stale_days: 30 # used by --stale without a number
69
- worktree_dir: "../%<repo>s.worktrees/%<branch>s" # only these two placeholders, up to 512 characters
70
- snapshot_exclude: ["*.pem", ".env*", "secrets"] # untracked files snapshots never copy (a directory covers all below it); both files add up
71
- ```
118
+ ## Undo
72
119
 
73
- Snapshots copy every untracked file that is not ignored (up to 50 MB) into git objects under `refs/gitflash/snapshots/`. A stray secret that is not in `.gitignore` is copied too, and a `git push --mirror` or a `refs/*` refspec would publish it. Ignore such files, or list them in `snapshot_exclude`; files left out this way cannot be restored by `gitflash undo`.
120
+ 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.
74
121
 
75
- ### Undo
76
-
77
- 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.
122
+ 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.
78
123
 
79
124
  ```bash
80
125
  gitflash delete old-feature --yes
@@ -86,93 +131,58 @@ gitflash undo --yes
86
131
 
87
132
  `gitflash undo` restores only what the snapshot saved and what differs, and saves the current state first, so an undo can itself be undone.
88
133
 
89
- ### Protect plain git commands run by AI agents
90
-
91
- 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:
134
+ > [!WARNING]
135
+ > Snapshots copy untracked files that are not ignored, including a stray secret, into git objects. A `git push --mirror` or a `refs/*` refspec would publish them. Ignore such files or list them in `snapshot_exclude`; see [Configuration](https://github.com/Kleanthismits/git_flash/blob/main/docs/configuration.md#snapshots-and-secrets).
92
136
 
93
- ```bash
94
- gitflash hook install
95
- ```
137
+ ## Parallel agent work
96
138
 
97
- 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. If the hook itself fails, `ask` mode still asks and `deny` mode blocks the command.
139
+ `gitflash wt add feature` creates a worktree (default `../<repo>.worktrees/feature`) and marks its new branch as agent work. `gitflash wt list --json` gives every worktree's absolute path, branch, dirty state, ahead/behind, merge status, lock state and owner, so an agent never has to switch directories.
98
140
 
99
- The same install adds a second hook that runs after a command: when the agent creates a branch with plain git (`git checkout -b`, `git switch -c`, `git branch NAME`, `git worktree add`), gitflash marks it as agent work, so `gitflash clean --agent` can find it. Only new, unmarked branches are marked, and `gitflash mark BRANCH --owner human` changes it. If you installed the hook with an earlier version, run `gitflash hook install` again to add it (the installed `--mode` is kept; a weaker mode needs `--downgrade`); `gitflash hook status` says whether branch marking is on.
100
-
101
- Where the hook goes, following Claude Code's own rules:
102
-
103
- | Scope | File | Applies to |
104
- | --- | --- | --- |
105
- | `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 |
106
- | `project` | `.claude/settings.json` at the repository top level, to share with your team | Sessions started in that directory |
107
- | `user` | `~/.claude/settings.json` | Every session |
141
+ For a branch made with plain git, run `gitflash mark` on it (no argument marks the current branch). The owner defaults to `human` when stdin is a terminal and `agent` otherwise, so a script run from a terminal should pass `--owner agent`.
108
142
 
109
- 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.
143
+ `gitflash wt remove` and `gitflash wt clean` save a snapshot first, and `gitflash undo` adds the worktree back with its branch and uncommitted files. Ignored files (`.env`, `node_modules`) are not saved: the plan lists them, and removing a worktree that has some needs `--confirm TOKEN` instead of `--yes`. A worktree with an untracked file over 50 MB is not removed.
110
144
 
111
- ### Scripts
145
+ `gitflash clean` keeps a branch nobody has committed to since it was created (it only looks merged); `--stale` removes it once it has been unused that long. Protected branches and branches checked out in a worktree are never touched.
112
146
 
113
- Without an argument, a command shows an interactive list. With arguments it runs directly, which also works in scripts.
114
-
115
- Options for every command:
116
-
117
- | Option | Effect |
118
- | --- | --- |
119
- | `--json` | Print JSON instead of text. Never shows lists or prompts |
120
- | `--yes`, `-y` | Skip confirmation prompts |
121
- | `--dry-run` | Show what would happen without changing anything |
122
-
123
- Run `gitflash help <command>` for details on a command.
147
+ `gitflash pick other-branch --list` lists the commits missing here (a commit whose patch is already here is marked `=`). `gitflash pick other-branch SHA... --yes` applies them oldest first with `-x`. On a conflict it stops and lists the files; resolve them and run `gitflash pick --continue`, or `--skip` / `--abort`.
124
148
 
125
- ### Scripts and AI agents
149
+ ## Scripts and agents
126
150
 
127
- Without a terminal (for example in a script, CI or an AI coding agent), gitflash never waits for input:
151
+ Every command takes `--json`, `--yes` and `--dry-run`. Without a terminal, gitflash never waits for input: a missing argument or an unconfirmed destructive change fails with exit code 2 and returns the plan.
128
152
 
129
- - A command that needs a list selection fails with exit code 2 and says which argument to pass.
130
- - A destructive action (`delete`, `reset --hard`) fails with exit code 2 and returns the plan unless you pass `--yes`.
131
- - A change `gitflash undo` cannot revert (`gc`, which deletes snapshots, and `wt remove` or `wt clean` of a worktree with ignored files) is not covered by `--yes`: it fails with exit code 2 and names a token (with `--json`, the plan and `error.confirm_token`; `--dry-run` shows the plan as text). Re-run with `--confirm TOKEN` to make that exact change; in a terminal you type the token instead.
153
+ ```bash
154
+ gitflash delete old-feature --yes --json
155
+ ```
132
156
 
133
- With `--json`, every command prints one object in the same envelope, defined in [schema/v1.json](schema/v1.json) (also printed by `gitflash schema`):
157
+ Every `--json` answer uses one envelope with `status`, `plan`, `result`, `undo` and a stable `error.code`. Exit codes are `0` success, `1` a git command failed, `2` invalid usage or confirmation required. The full contract is in [JSON contract](https://github.com/Kleanthismits/git_flash/blob/main/docs/json-contract.md).
134
158
 
135
- ```json
136
- {
137
- "schema": 1,
138
- "command": "delete",
139
- "ok": true,
140
- "status": "done",
141
- "dry_run": false,
142
- "plan": { "branches": ["old-feature"], "force": false },
143
- "result": { "deleted": [{ "branch": "old-feature", "sha": "3551cba" }], "failed": [] }
144
- }
145
- ```
159
+ ## Configuration
146
160
 
147
- - `status`: `done`, `planned` (`--dry-run`), `noop`, `cancelled`, `failed`, `confirmation_required` or `error`. `ok` is true for the first four.
148
- - `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`.
149
- - `undo` names the snapshot saved before the change and the command that restores it (`gitflash undo ID`).
150
- - `error.code` is a stable identifier such as `unknown_branch`, `protected_branch` or `confirmation_required`.
161
+ Optional settings live in `.gitflash.yml` in the repository and `~/.config/gitflash.yml`: extra protected branch patterns, the default for `--stale`, where worktrees go, and files snapshots never copy. See [Configuration](https://github.com/Kleanthismits/git_flash/blob/main/docs/configuration.md).
151
162
 
152
- ```bash
153
- gitflash branches --merged --json
154
- ```
163
+ ## How it compares
155
164
 
156
- ```bash
157
- gitflash delete old-feature another-branch --yes --json
158
- ```
165
+ | Tool | Difference |
166
+ | --- | --- |
167
+ | jj (Jujutsu) | Has an operation log and `jj undo`, but you adopt a new version control tool |
168
+ | GitButler | Has an MCP server and Claude Code hooks, but needs the GitButler app and its branch model |
169
+ | `mcp-server-git` and other git MCP servers | Give agents git actions, but no undo for what the agent changed |
170
+ | Claude Code checkpoints | Undo Claude's file edits, but not changes made through shell commands |
159
171
 
160
- Exit codes: `0` success, `1` a git command failed (for example a branch was not fully merged), `2` invalid usage or confirmation required.
172
+ gitflash adds undo for plain git, for the operations agent tools do not track, with nothing new to adopt.
161
173
 
162
174
  ## Development
163
175
 
164
- 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.
176
+ After checking out the repository, run `bin/setup` to install dependencies. Then run `bundle exec rake spec` for the tests and `bundle exec rubocop` for the linter. `bin/console` opens an interactive prompt.
165
177
 
166
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
178
+ To install the gem from your checkout, run `bundle exec rake install`.
179
+
180
+ To release, bump the version in `lib/gitflash/version.rb` and `.claude-plugin/plugin.json` (a spec checks they match), move the `[Unreleased]` notes in `CHANGELOG.md` under the new version, and run `bundle exec rake release`. It tags the version, pushes the tag and publishes the gem to [rubygems.org](https://rubygems.org/gems/gitflash).
167
181
 
168
182
  ## Contributing
169
183
 
170
- Bug reports and pull requests are welcome on GitHub at https://github.com/Kleanthismits/git_flash. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/Kleanthismits/git_flash/blob/main/CODE_OF_CONDUCT.md).
184
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/Kleanthismits/git_flash/issues). Everyone interacting in the project is expected to follow the [code of conduct](https://github.com/Kleanthismits/git_flash/blob/main/CODE_OF_CONDUCT.md).
171
185
 
172
186
  ## License
173
187
 
174
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
175
-
176
- ## Code of Conduct
177
-
178
- Everyone interacting in the GitFlash project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/Kleanthismits/git_flash/blob/main/CODE_OF_CONDUCT.md).
188
+ Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -6,10 +6,12 @@ module Gitflash
6
6
  # A local branch as reported by `git for-each-ref`
7
7
  Branch = Data.define(
8
8
  :name, :sha, :current, :default, :upstream, :upstream_gone, :ahead, :behind,
9
- :merged, :last_commit_at, :last_commit_author, :last_commit_subject, :owner
9
+ :merged, :last_commit_at, :last_commit_author, :last_commit_subject, :owner,
10
+ :unused, :last_activity_at
10
11
  ) do
11
- # Records are built without an owner; Ownership#annotate adds it
12
- def initialize(owner: nil, **fields)
12
+ # Records are built without an owner; Ownership#annotate adds it. `unused` and
13
+ # `last_activity_at` come from the reflog (BranchActivity) and are nil when it is not read.
14
+ def initialize(owner: nil, unused: nil, last_activity_at: nil, **fields)
13
15
  super
14
16
  end
15
17
 
@@ -53,12 +55,15 @@ module Gitflash
53
55
  alias_method :default?, :default
54
56
  alias_method :upstream_gone?, :upstream_gone
55
57
 
58
+ # Neither a commit nor a move of the branch (reflog) for `days` days: a branch cut today from
59
+ # an old commit is not stale
56
60
  def stale?(days, now: Time.now)
57
- last_commit_at < now - (days * 86_400)
61
+ [last_commit_at, last_activity_at].compact.max < now - (days * 86_400)
58
62
  end
59
63
 
60
64
  def to_h
61
- super.merge(last_commit_at: last_commit_at.iso8601)
65
+ super.merge(last_commit_at: last_commit_at.iso8601,
66
+ last_activity_at: last_activity_at&.iso8601)
62
67
  end
63
68
  end
64
69
  Branch::TIPS_FORMAT = '%(refname) %(objectname)'
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # What the reflog of a branch says about its use: when it last moved, and whether it never
5
+ # moved after it was created (`unused`: nobody committed, reset or merged anything on it, so
6
+ # its tip is still the commit it was cut from). A commit date cannot tell this: a branch cut
7
+ # today from an old commit has an old commit date and is reachable from where it was cut.
8
+ BranchActivity = Data.define(:unused, :last_activity_at) do
9
+ # The branches with `unused` and `last_activity_at` set from their reflogs, read in one git
10
+ # call. Without a reflog, or when git fails, a branch is returned as it is.
11
+ def self.annotate(branches, bash: Git::BashCommand)
12
+ return branches if branches.empty?
13
+
14
+ refs = branches.map { |branch| "#{Repo::HEADS}#{branch.name}" }
15
+ format = "--format=#{BranchActivity::FORMAT}"
16
+ found = parse(bash.exec('git', 'log', '-g', '--date=unix', format, *refs, '--'))
17
+ branches.map do |branch|
18
+ activity = found[branch.name]
19
+ activity ? branch.with(**activity.to_h) : branch
20
+ end
21
+ rescue Git::CommandError
22
+ branches
23
+ end
24
+
25
+ # { branch name => BranchActivity } from `git log -g --date=unix --format=FORMAT REFS...`,
26
+ # which lists the entries of each ref newest first. A branch without a reflog (reflogs off,
27
+ # or its entries expired) has no entry, so nothing is known about it.
28
+ def self.parse(output)
29
+ entries = output.to_s.each_line(chomp: true).filter_map { |line| entry(line) }
30
+ entries.group_by(&:first).transform_values do |rows|
31
+ new(unused: rows.size == 1 && rows.first[2].start_with?(BranchActivity::CREATED),
32
+ last_activity_at: Time.at(rows.first[1]))
33
+ end
34
+ end
35
+
36
+ # [branch name, unix time, subject]. A ref name cannot contain `@{`, so the selector is the
37
+ # last one.
38
+ def self.entry(line)
39
+ selector, subject = line.split(Repo::SEPARATOR, 2)
40
+ ref, time = selector.to_s.match(/\A(.+)@\{(\d+)\}\z/)&.captures
41
+ ref && [ref.delete_prefix(Repo::HEADS), time.to_i, subject.to_s]
42
+ end
43
+ private_class_method :entry
44
+ end
45
+ BranchActivity::CREATED = 'branch: Created from'
46
+ BranchActivity::FORMAT = '%gD%x1f%gs'
47
+ end
@@ -6,19 +6,38 @@ module Gitflash
6
6
  module Cleanup
7
7
  CRITERIA = %i[merged gone stale agent].freeze
8
8
  DEFAULT_CRITERIA = %i[merged gone].freeze
9
+ # Criteria that do not choose an unused branch
10
+ NEW_BRANCH_SAFE = %i[merged agent].freeze
9
11
 
10
12
  # A record chosen for removal and why. `item` is a Branch or a Worktree.
11
13
  Candidate = Data.define(:item, :reasons)
12
14
 
13
15
  # Which criteria are active. `stale_days` applies to :stale.
16
+ #
17
+ # A branch nobody has used since it was created (Branch#unused) is new, not finished, even
18
+ # though it is reachable from the default branch and may be marked as agent work: someone
19
+ # may be about to work on it. :merged and :agent leave it alone; :stale removes it once it
20
+ # has been unused for `stale_days`, and :gone when its remote branch was deleted.
14
21
  Rules = Data.define(:criteria, :stale_days, :now) do
15
22
  def reasons(branch)
16
23
  criteria.filter_map { |criterion| label(criterion) if match?(criterion, branch) }
17
24
  end
18
25
 
26
+ # Why a branch that a criterion would have chosen if it were not unused is kept, or nil
27
+ def held_back(branch)
28
+ return nil unless branch.unused && reasons(branch).empty?
29
+ return nil unless criteria.any? { |criterion| fits?(criterion, branch) }
30
+
31
+ "no commits since it was created; --stale removes it after #{stale_days} days unused"
32
+ end
33
+
19
34
  private
20
35
 
21
36
  def match?(criterion, branch)
37
+ fits?(criterion, branch) && !(branch.unused && NEW_BRANCH_SAFE.include?(criterion))
38
+ end
39
+
40
+ def fits?(criterion, branch)
22
41
  case criterion
23
42
  when :merged then branch.merged && !branch.default?
24
43
  when :gone then branch.upstream_gone?
@@ -43,13 +62,35 @@ module Gitflash
43
62
  end
44
63
  end
45
64
 
65
+ # Unprotected unused branches that a criterion would have chosen otherwise, as
66
+ # { branch:, reason: } rows
67
+ def held_back(branches, rules:, protection:)
68
+ branches.reject { |branch| protection.protected?(branch) }.filter_map do |branch|
69
+ reason = rules.held_back(branch)
70
+ { branch: branch.name, reason: reason } if reason
71
+ end
72
+ end
73
+
46
74
  # Worktrees whose unprotected branch matches, as [chosen, skipped]. `blocker` is called with
47
75
  # a worktree and returns why it may not be removed, or nil. A blocked match is skipped with
48
76
  # that reason instead of being chosen.
77
+ # A worktree whose branch is unused is skipped with that reason too.
49
78
  def worktrees(worktrees, branches:, rules:, protection:, blocker:)
50
79
  matched = matching(worktrees, branches, rules, protection)
51
80
  chosen, skipped = matched.partition { |candidate| blocker.call(candidate.item).nil? }
52
- [chosen, skipped.map { |candidate| skip(candidate.item, blocker.call(candidate.item)) }]
81
+ [chosen, skipped.map { |candidate| skip(candidate.item, blocker.call(candidate.item)) } +
82
+ held_back_worktrees(worktrees, branches, rules, protection)]
83
+ end
84
+
85
+ def held_back_worktrees(worktrees, branches, rules, protection)
86
+ by_name = branches.to_h { |branch| [branch.name, branch] }
87
+ worktrees.filter_map do |worktree|
88
+ branch = by_name[worktree.branch]
89
+ next if branch.nil? || protection.protected?(branch)
90
+
91
+ reason = rules.held_back(branch)
92
+ skip(worktree, "#{worktree.branch} has #{reason}") if reason
93
+ end
53
94
  end
54
95
 
55
96
  def matching(worktrees, branches, rules, protection)
data/lib/gitflash/cli.rb CHANGED
@@ -125,6 +125,9 @@ module Gitflash
125
125
  desc 'hook SUBCOMMAND', 'Claude Code hooks: install them and check they are active'
126
126
  subcommand 'hook', HookCli
127
127
 
128
+ desc 'skill SUBCOMMAND', 'The Agent Skill that teaches agents to use gitflash'
129
+ subcommand 'skill', SkillCli
130
+
128
131
  desc 'pick [SOURCE] [SHA...]', descriptions.pick.short
129
132
  long_desc descriptions.pick.long
130
133
  option :list, type: :boolean, desc: 'List the commits of SOURCE that are not on this branch yet'
@@ -141,6 +144,35 @@ module Gitflash
141
144
  subcommand 'worktree', WorktreeCli
142
145
  map 'wt' => :worktree
143
146
 
147
+ desc 'mcp', 'Run an MCP server on stdio for the repository in the current directory'
148
+ long_desc <<~TEXT
149
+ Serves gitflash to MCP clients (Claude Code, Claude Desktop, other agents) over stdio,
150
+ for the git repository in the current directory only. Tools answer with the JSON envelope
151
+ of `gitflash schema`; outside a git repository they answer with not_a_repository.
152
+
153
+ Read-only tools: list_branches, list_worktrees, list_pickable_commits, list_snapshots,
154
+ plan_change (the plan and confirm_token of a destructive tool, changing nothing).
155
+ Tools that lose nothing: add_worktree, cherry_pick_commits, cherry_pick_control,
156
+ mark_branches, save_snapshot.
157
+ Destructive tools: delete_branches, clean_branches, reset_branch, remove_worktrees,
158
+ move_worktree, undo. Their first call returns the plan and error.confirm_token; a second
159
+ call with confirm_token (valid 5 minutes, for that plan only) makes the change, after a
160
+ snapshot.
161
+
162
+ When the client supports elicitation, a destructive tool first asks you to approve its plan
163
+ in the client, and runs at once when you do (on a 2026-07-28 connection the form is an
164
+ input_required result). Without an answer within 2 minutes, or with
165
+ --no-elicitation (for clients that never answer), it falls back to the token and writes
166
+ the reason to stderr, which MCP clients keep in their logs.
167
+
168
+ Register it in Claude Code with: claude mcp add gitflash -- gitflash mcp
169
+ TEXT
170
+ option :elicitation, type: :boolean, default: true,
171
+ desc: 'Ask the user through the client before destructive changes'
172
+ def mcp
173
+ Mcp::Server.new(elicitation: Mcp::Elicitation.new(enabled: options[:elicitation])).start
174
+ end
175
+
144
176
  desc 'schema', 'Print the JSON Schema of the --json output'
145
177
  def schema
146
178
  puts File.read(SCHEMA_PATH)
@@ -20,12 +20,22 @@ module Gitflash
20
20
 
21
21
  private
22
22
 
23
- # Sets @reasons (chosen branch => why) and @skipped (matches kept for being unmerged)
23
+ # Sets @reasons (chosen branch => why) and @skipped (matches kept for being unmerged, and
24
+ # unused branches)
24
25
  def select(branches)
25
- candidates = Cleanup.branches(branches, rules: rules, protection: protection)
26
- chosen, kept = candidates.partition { |candidate| force? || candidate.item.merged }
26
+ chosen, kept = candidates(branches)
27
27
  @reasons = chosen.to_h { |candidate| [candidate.item.name, candidate.reasons] }
28
- @skipped = kept.map { |candidate| skipped_row(candidate) }
28
+ @skipped = kept.map { |candidate| skipped_row(candidate) } + held_back(branches)
29
+ end
30
+
31
+ # [chosen, kept for being unmerged]
32
+ def candidates(branches)
33
+ Cleanup.branches(branches, rules: rules, protection: protection)
34
+ .partition { |candidate| force? || candidate.item.merged }
35
+ end
36
+
37
+ def held_back(branches)
38
+ Cleanup.held_back(branches, rules: rules, protection: protection)
29
39
  end
30
40
 
31
41
  def build_plan(names)