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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +124 -114
- data/lib/gitflash/branch.rb +10 -5
- data/lib/gitflash/branch_activity.rb +47 -0
- data/lib/gitflash/cleanup.rb +42 -1
- data/lib/gitflash/cli.rb +32 -0
- data/lib/gitflash/commands/clean.rb +14 -4
- data/lib/gitflash/commands/skill_install.rb +118 -0
- data/lib/gitflash/hook/branch_marker.rb +17 -11
- data/lib/gitflash/hook/command_parser.rb +13 -6
- data/lib/gitflash/hook/heredocs.rb +120 -0
- data/lib/gitflash/hook/shell_text.rb +52 -19
- data/lib/gitflash/mcp/confirmation.rb +61 -0
- data/lib/gitflash/mcp/elicitation.rb +122 -0
- data/lib/gitflash/mcp/plan_change.rb +53 -0
- data/lib/gitflash/mcp/runner.rb +41 -0
- data/lib/gitflash/mcp/server.rb +148 -0
- data/lib/gitflash/mcp/tools.rb +195 -0
- data/lib/gitflash/repo.rb +4 -4
- data/lib/gitflash/reused_snapshot.rb +11 -0
- data/lib/gitflash/skill_cli.rb +46 -0
- data/lib/gitflash/snapshot.rb +3 -0
- data/lib/gitflash/snapshots.rb +3 -2
- data/lib/gitflash/ui.rb +8 -2
- data/lib/gitflash/version.rb +1 -1
- data/schema/v1.json +74 -0
- data/skills/gitflash/SKILL.md +95 -0
- metadata +32 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1c0cf7d7078454db30329d4dcc8308117ef79d36e3fc591e9d0dc430c039d5a6
|
|
4
|
+
data.tar.gz: 955eda7164005c0edf87de99c2d8e2eba3871d79bf5fd02b2c41df6c80ca6c0b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
1
|
+
# gitflash
|
|
2
2
|
|
|
3
|
-
[](https://rubygems.org/gems/gitflash)
|
|
4
|
+
[](https://github.com/Kleanthismits/git_flash/actions/workflows/rspec.yml)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
**A safety net and cleanup tool for git repositories that AI coding agents work in.**
|
|
6
7
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
gem 'gitflash'
|
|
22
|
+
gitflash undo --yes # HEAD, branches, uncommitted and untracked files come back
|
|
17
23
|
```
|
|
18
24
|
|
|
19
|
-
|
|
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
|
-
|
|
72
|
+
## Installation
|
|
22
73
|
|
|
23
|
-
|
|
74
|
+
```bash
|
|
75
|
+
gem install gitflash
|
|
76
|
+
```
|
|
24
77
|
|
|
25
|
-
|
|
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
|
-
##
|
|
82
|
+
## Quick start for agents
|
|
30
83
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
+
Do not combine the plugin with `gitflash hook install`, or the hook runs twice.
|
|
36
93
|
|
|
37
|
-
|
|
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
|
|
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
|
|
42
|
-
| `gitflash reset [COMMIT]` | Reset to a commit
|
|
43
|
-
| `gitflash
|
|
44
|
-
| `gitflash
|
|
45
|
-
| `gitflash worktree` (`wt`) `list`, `add`, `remove`, `clean`, `prune`, `lock`, `unlock`, `move`
|
|
46
|
-
| `gitflash pick [SOURCE] [SHA...]` | Cherry-pick commits from another branch (
|
|
47
|
-
| `gitflash undo [SNAPSHOT]` | Restore a snapshot, by default the latest one taken in this worktree
|
|
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
|
|
51
|
-
| `gitflash hook install` | Protect plain git commands run by Claude Code and
|
|
52
|
-
| `gitflash
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
gitflash hook install
|
|
95
|
-
```
|
|
137
|
+
## Parallel agent work
|
|
96
138
|
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
+
## Scripts and agents
|
|
126
150
|
|
|
127
|
-
Without a terminal
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
153
|
+
```bash
|
|
154
|
+
gitflash delete old-feature --yes --json
|
|
155
|
+
```
|
|
132
156
|
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
gitflash branches --merged --json
|
|
154
|
-
```
|
|
163
|
+
## How it compares
|
|
155
164
|
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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).
|
data/lib/gitflash/branch.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/gitflash/cleanup.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|