gitflash 0.6.0 → 0.7.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 +14 -0
- data/README.md +26 -2
- data/command_descriptions.yml +76 -0
- data/lib/gitflash/branch.rb +7 -2
- data/lib/gitflash/cherry_pick.rb +128 -0
- data/lib/gitflash/cleanup.rb +70 -0
- data/lib/gitflash/cli.rb +37 -0
- data/lib/gitflash/commands/base.rb +9 -3
- data/lib/gitflash/commands/branches.rb +1 -0
- data/lib/gitflash/commands/clean.rb +51 -0
- data/lib/gitflash/commands/clean_criteria.rb +29 -0
- data/lib/gitflash/commands/delete.rb +17 -5
- data/lib/gitflash/commands/hook_install.rb +17 -9
- data/lib/gitflash/commands/hook_status.rb +24 -6
- data/lib/gitflash/commands/mark.rb +85 -0
- data/lib/gitflash/commands/pick.rb +131 -0
- data/lib/gitflash/commands/pick_control.rb +67 -0
- data/lib/gitflash/commands/pick_outcome.rb +52 -0
- data/lib/gitflash/commands/removal_report.rb +65 -0
- data/lib/gitflash/commands/undo.rb +38 -6
- data/lib/gitflash/commands/worktree_add.rb +98 -0
- data/lib/gitflash/commands/worktree_clean.rb +49 -0
- data/lib/gitflash/commands/worktree_command.rb +28 -0
- data/lib/gitflash/commands/worktree_list.rb +48 -0
- data/lib/gitflash/commands/worktree_lock.rb +54 -0
- data/lib/gitflash/commands/worktree_move.rb +47 -0
- data/lib/gitflash/commands/worktree_prune.rb +41 -0
- data/lib/gitflash/commands/worktree_remove.rb +119 -0
- data/lib/gitflash/commands/worktree_unlock.rb +12 -0
- data/lib/gitflash/config.rb +95 -0
- data/lib/gitflash/config_keys.rb +19 -0
- data/lib/gitflash/git/in_directory.rb +29 -0
- data/lib/gitflash/hook/branch_marker.rb +52 -0
- data/lib/gitflash/hook/claude.rb +23 -3
- data/lib/gitflash/hook/command_parser.rb +29 -9
- data/lib/gitflash/hook/creations.rb +85 -0
- data/lib/gitflash/hook/settings.rb +18 -5
- data/lib/gitflash/hook_cli.rb +12 -4
- data/lib/gitflash/ownership.rb +59 -0
- data/lib/gitflash/protection.rb +19 -0
- data/lib/gitflash/repo.rb +2 -3
- data/lib/gitflash/restore.rb +8 -7
- data/lib/gitflash/state_capture.rb +2 -2
- data/lib/gitflash/version.rb +1 -1
- data/lib/gitflash/worktree.rb +32 -0
- data/lib/gitflash/worktree_cli.rb +115 -0
- data/lib/gitflash/worktree_revival.rb +55 -0
- data/lib/gitflash/worktrees.rb +147 -0
- data/schema/v1.json +1050 -2
- metadata +30 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8466fec203a9fdd7ac08e721fb0bb624fb8f486e68f9b369660e5d369e717ad
|
|
4
|
+
data.tar.gz: 53243a2e0dc1d5877bd5541999fec636e89ac882b9af78d84790ef32be3e12cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 27b78d4baac19b15a9f6bef731cc0ec3622ee5c51a2ee82ab9f20a63a5f50d6ecedb3c4f1651d0849eae970f5a66c20480272d0bf00b0284e8434f1893805901
|
|
7
|
+
data.tar.gz: 1c1f6e76de4e2f67fb7c1c591e9569d8578a96cad8cc9e58ff75fd9448abaa8ead4e7b96cef519d6a4a991e75299e07eaafcc8d060896b8742b9c7f4ec4b93f5
|
data/CHANGELOG.md
CHANGED
|
@@ -73,3 +73,17 @@ Security fixes for names and text that come from the repository:
|
|
|
73
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
74
|
- Add `hook status` to show whether the hook protects Claude Code sessions in the current directory
|
|
75
75
|
- JSON schema: new `undo` field, commands `undo`, `snapshots`, `snapshot`, `gc`, `hook`, error codes `unknown_snapshot`, `wrong_worktree`, `invalid_settings`
|
|
76
|
+
|
|
77
|
+
## [0.7.0.beta1] - 2026-10-07
|
|
78
|
+
|
|
79
|
+
Beta of the parallel agent work release. Install with `gem install gitflash --pre`. The JSON contract of the new commands may still change before 0.7.0. Needs git 2.36 or newer.
|
|
80
|
+
|
|
81
|
+
- Settings: `.gitflash.yml` in the repository and `~/.config/gitflash.yml` (the repository file wins) with `protected` branch patterns, `stale_days` and `worktree_dir`. Unknown keys, wrong types, duplicate keys and unreadable files fail with the new error code `invalid_config`
|
|
82
|
+
- Ownership marks: `branch.<name>.gitflash-owner` (`agent` or `human`) in git config, shown as `owner` in `branches` and `worktree list`. Add `mark [BRANCH...] [--owner agent|human] [--clear]` to set it
|
|
83
|
+
- Add `worktree` (alias `wt`): `list`, `add`, `remove`, `clean`, `prune`, `lock`, `unlock` and `move`. `wt list --json` gives absolute paths, dirty state, ahead and behind, merge status, lock state and owner. `wt add` marks the branches it creates as agent work. `wt remove` and `wt clean` save a snapshot first and refuse the main checkout, the current worktree, and locked or dirty worktrees unless `--force`. `undo` adds a removed worktree back with its branch and uncommitted files. Ignored files are not saved: the plan lists them before they are deleted. A worktree with an untracked file over 50 MB is not removed
|
|
84
|
+
- Add `clean`: deletes merged branches and branches whose upstream is gone, and with `--stale [DAYS]` and `--agent` stale and agent-created ones, with a snapshot first. Each chosen branch lists why. Unmerged branches need `--force`
|
|
85
|
+
- `clean` and `delete` never touch the current and default branch, `main`, `master`, `protected` branches or branches checked out in a worktree
|
|
86
|
+
- Add `pick SOURCE [SHA...]`: `--list` shows the commits of SOURCE that are not on the current branch (already applied patches are marked), `SHA...` applies them oldest first with `git cherry-pick -x` after a snapshot, and on a conflict it stops and lists the files. `pick --continue`, `--skip` and `--abort` wrap git's own controls
|
|
87
|
+
- The Claude Code hook also runs after Bash commands (`PostToolUse`): branches created with plain git (`checkout -b`, `switch -c`, `branch NAME`, `worktree add`) are marked as agent work. `hook install` adds it, so run it again after upgrading; `hook status` reports whether marking is on. A gitflash hook under a matcher that does not cover Bash no longer counts as installed
|
|
88
|
+
- JSON schema: new commands `clean`, `mark`, `pick` and `worktree ...`, error codes `invalid_config`, `unknown_worktree`, `protected_worktree` and `conflict`, the `owner` field, and `recreate_worktree` in the `undo` plan
|
|
89
|
+
- Author names from other branches are shown with control characters escaped in `pick` output
|
data/README.md
CHANGED
|
@@ -24,9 +24,11 @@ Or install it yourself as:
|
|
|
24
24
|
|
|
25
25
|
$ gem install gitflash
|
|
26
26
|
|
|
27
|
+
Prerelease versions such as `0.7.0.beta1` are only installed when you ask for them: `gem install gitflash --pre`, or `gem 'gitflash', '0.7.0.beta1'` in a Gemfile.
|
|
28
|
+
|
|
27
29
|
## Requirements
|
|
28
30
|
|
|
29
|
-
This gem requires Ruby 3.3+ and git.
|
|
31
|
+
This gem requires Ruby 3.3+ and git 2.36+ (the worktree commands read `git worktree list -z`).
|
|
30
32
|
|
|
31
33
|
## Usage
|
|
32
34
|
|
|
@@ -38,15 +40,35 @@ Run `gitflash` inside a git repository to get a list with the available commands
|
|
|
38
40
|
| `gitflash checkout [BRANCH]` | Check out a branch, or pick one from a list |
|
|
39
41
|
| `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
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 (default) or human work, 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`, `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: `--list` shows what is missing here, `--continue`, `--skip` and `--abort` handle conflicts |
|
|
41
47
|
| `gitflash undo [SNAPSHOT]` | Restore a snapshot, the latest by default: deleted branches, moved branches, HEAD, uncommitted and untracked files, dropped stashes |
|
|
42
48
|
| `gitflash snapshot` | Save the current state on request (`--scope`, `--message`) |
|
|
43
49
|
| `gitflash snapshots` | List snapshots, newest first |
|
|
44
50
|
| `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) |
|
|
51
|
+
| `gitflash hook install` | Protect plain git commands run by Claude Code and mark the branches it creates (see below) |
|
|
46
52
|
| `gitflash hook status` | Show whether the hook protects Claude Code sessions in the current directory |
|
|
47
53
|
| `gitflash schema` | Print the JSON Schema of the `--json` output |
|
|
48
54
|
| `gitflash version` | Print the installed version (also `--version`, `-v`) |
|
|
49
55
|
|
|
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. 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):
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
protected: ["release/*"] # branch patterns clean and delete never touch
|
|
68
|
+
stale_days: 30 # used by --stale without a number
|
|
69
|
+
worktree_dir: "../%<repo>s.worktrees/%<branch>s"
|
|
70
|
+
```
|
|
71
|
+
|
|
50
72
|
### Undo
|
|
51
73
|
|
|
52
74
|
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.
|
|
@@ -71,6 +93,8 @@ gitflash hook install
|
|
|
71
93
|
|
|
72
94
|
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
95
|
|
|
96
|
+
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; `gitflash hook status` says whether branch marking is on.
|
|
97
|
+
|
|
74
98
|
Where the hook goes, following Claude Code's own rules:
|
|
75
99
|
|
|
76
100
|
| Scope | File | Applies to |
|
data/command_descriptions.yml
CHANGED
|
@@ -69,3 +69,79 @@ snapshot:
|
|
|
69
69
|
Untracked files larger than 50 MB are skipped and listed.
|
|
70
70
|
|
|
71
71
|
Use --scope to save only some parts, for example --scope worktree.
|
|
72
|
+
|
|
73
|
+
clean:
|
|
74
|
+
short: 'Delete merged, gone, stale or agent-created branches'
|
|
75
|
+
long: |
|
|
76
|
+
Deletes branches that are no longer needed, with a snapshot first so `gitflash undo`
|
|
77
|
+
brings them back.
|
|
78
|
+
|
|
79
|
+
Without options it selects branches merged into the default branch and branches whose
|
|
80
|
+
upstream was deleted. Add criteria to select more (a branch is chosen when it matches any):
|
|
81
|
+
--merged merged into the default branch
|
|
82
|
+
--gone upstream branch was deleted on the remote
|
|
83
|
+
--stale [DAYS] no commits for DAYS days (default: `stale_days` setting, 30)
|
|
84
|
+
--agent created by an agent (marked by `gitflash wt add`)
|
|
85
|
+
|
|
86
|
+
Never selected: the current and default branch, main, master, `protected` branches from
|
|
87
|
+
.gitflash.yml, and branches checked out in any worktree. Unmerged branches are kept unless
|
|
88
|
+
--force is given. Every chosen branch lists why it was chosen.
|
|
89
|
+
|
|
90
|
+
Asks for confirmation; pass --yes to skip it (required without a terminal), or --dry-run.
|
|
91
|
+
|
|
92
|
+
worktree_remove:
|
|
93
|
+
long: |
|
|
94
|
+
Removes the given worktrees, given as a path or the branch checked out in them. Without
|
|
95
|
+
arguments, shows a menu in a terminal. The main checkout and the current worktree are
|
|
96
|
+
never removed; locked worktrees and worktrees with uncommitted changes are refused
|
|
97
|
+
unless --force is given.
|
|
98
|
+
|
|
99
|
+
Each removal saves a snapshot first, including uncommitted and untracked files. Ignored
|
|
100
|
+
files (build output, dependencies, .env) are not saved: the plan and the confirmation list
|
|
101
|
+
them, since removal deletes them for good. A worktree with an untracked file over 50 MB
|
|
102
|
+
is not removed, because the snapshot cannot hold it. `gitflash undo ID` brings the
|
|
103
|
+
directory, its branch and the saved files back.
|
|
104
|
+
|
|
105
|
+
Asks for confirmation; pass --yes to skip it (required without a terminal), or --dry-run.
|
|
106
|
+
Accepts the global options after the subcommand: gitflash wt remove feature --yes --json
|
|
107
|
+
|
|
108
|
+
pick:
|
|
109
|
+
short: 'Cherry-pick commits from another branch'
|
|
110
|
+
long: |
|
|
111
|
+
Moves fixes between branches, for example between agent branches.
|
|
112
|
+
|
|
113
|
+
`gitflash pick SOURCE --list` lists the commits of SOURCE that are not on the current branch
|
|
114
|
+
yet, oldest first, with SHA, subject, author, date and a diff summary. A commit whose change
|
|
115
|
+
is already here (the same patch under another SHA) is marked `=` (`applied` in JSON) and is
|
|
116
|
+
never picked. Merge commits are left out.
|
|
117
|
+
|
|
118
|
+
`gitflash pick SOURCE SHA...` applies the given commits, oldest first whatever the order
|
|
119
|
+
given, with `git cherry-pick -x` (the message records the origin). Without SHAs, a menu in a
|
|
120
|
+
terminal lets you choose. Add --no-commit to only stage the combined changes.
|
|
121
|
+
|
|
122
|
+
A snapshot is saved first and the plan asks for confirmation (pass --yes, required without a
|
|
123
|
+
terminal, or --dry-run). `gitflash undo` returns to the starting point.
|
|
124
|
+
|
|
125
|
+
On a conflict the pick stops and the result lists the conflicted files, the commits already
|
|
126
|
+
applied and the ones still waiting. Resolve the files and stage them, then run
|
|
127
|
+
`gitflash pick --continue`. `gitflash pick --skip` drops the commit that conflicts and
|
|
128
|
+
`gitflash pick --abort` returns to the state before the pick (both ask for confirmation and
|
|
129
|
+
save a snapshot first).
|
|
130
|
+
|
|
131
|
+
For agents: `gitflash pick SOURCE --list --json` and
|
|
132
|
+
`gitflash pick SOURCE SHA... --yes --json`.
|
|
133
|
+
|
|
134
|
+
mark:
|
|
135
|
+
short: 'Mark branches as agent or human work'
|
|
136
|
+
long: |
|
|
137
|
+
Records who created a branch, so `gitflash clean --agent` only touches branches an agent
|
|
138
|
+
created. The mark is the git config key `branch.<name>.gitflash-owner` and disappears with
|
|
139
|
+
the branch.
|
|
140
|
+
|
|
141
|
+
Without BRANCH the current branch is marked. The owner is `agent` unless --owner human is
|
|
142
|
+
given; --clear removes the mark. A branch without a mark counts as human work.
|
|
143
|
+
|
|
144
|
+
`gitflash wt add` marks the branches it creates. Use `gitflash mark` for branches made with
|
|
145
|
+
plain git, for example at the start of an agent session: `gitflash mark`.
|
|
146
|
+
|
|
147
|
+
Needs no confirmation. Pass --dry-run to see what would change, --json for the result.
|
data/lib/gitflash/branch.rb
CHANGED
|
@@ -6,10 +6,15 @@ 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
|
|
9
|
+
:merged, :last_commit_at, :last_commit_author, :last_commit_subject, :owner
|
|
10
10
|
) do
|
|
11
|
+
# Records are built without an owner; Ownership#annotate adds it
|
|
12
|
+
def initialize(owner: nil, **fields)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
11
16
|
# Parses one line produced with Repo::BRANCH_FORMAT.
|
|
12
|
-
# `merged_names` is nil when merge status is unknown.
|
|
17
|
+
# `merged_names` is nil when merge status is unknown. `owner` is set by the caller.
|
|
13
18
|
def self.parse(line, default_branch: nil, merged_names: nil)
|
|
14
19
|
name, sha, head, upstream, track, date, author, subject = fields(line)
|
|
15
20
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
# Cherry-picking between branches: finds the commits of a source branch whose change is not on
|
|
5
|
+
# the current branch yet, applies them oldest first, and wraps git's own sequence controls.
|
|
6
|
+
# All git access for `gitflash pick` is here; it never prompts or prints.
|
|
7
|
+
class CherryPick
|
|
8
|
+
# `applied` is true when an equivalent change is already on the current branch
|
|
9
|
+
Candidate = Data.define(:sha, :subject, :author, :date, :applied, :files, :insertions,
|
|
10
|
+
:deletions) do
|
|
11
|
+
def short_sha = sha[0, 7]
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
super.merge(short_sha: short_sha, status: applied ? 'applied' : 'new')
|
|
15
|
+
.except(:applied)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The state of an unfinished pick: files with conflicts and the commits still to apply
|
|
20
|
+
Progress = Data.define(:conflicted, :remaining, :current)
|
|
21
|
+
|
|
22
|
+
RECORD = "\x1e"
|
|
23
|
+
SEPARATOR = "\x1f"
|
|
24
|
+
FORMAT = ['%m', '%H', '%s', '%an', '%cI'].join(SEPARATOR)
|
|
25
|
+
ENV_NO_EDITOR = { 'GIT_EDITOR' => 'true', 'GIT_SEQUENCE_EDITOR' => 'true' }.freeze
|
|
26
|
+
|
|
27
|
+
def initialize(bash: Git::BashCommand)
|
|
28
|
+
@bash = bash
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The full ref of a local (or remote-tracking) branch, nil when there is none
|
|
32
|
+
def source_ref(name)
|
|
33
|
+
return nil if name.to_s.empty? || name.start_with?('-')
|
|
34
|
+
|
|
35
|
+
["#{Repo::HEADS}#{name}", "refs/remotes/#{name}"].find { |ref| ref?(ref) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Commits of `source_ref` that are not merges, oldest first, each marked as applied when an
|
|
39
|
+
# equivalent change (same patch) is already on HEAD
|
|
40
|
+
def candidates(source_ref)
|
|
41
|
+
output = @bash.exec('git', 'log', '--left-right', '--cherry-mark', '--right-only',
|
|
42
|
+
'--no-merges', '--reverse', '--shortstat', "--format=#{RECORD}#{FORMAT}",
|
|
43
|
+
"HEAD...#{source_ref}", '--')
|
|
44
|
+
output.split(RECORD).reject { |record| record.strip.empty? }.map { |record| parse(record) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Applies the commits (full shas, in the given order) with `git cherry-pick`.
|
|
48
|
+
# `record_origin` adds "(cherry picked from ...)" to each message; `commit: false` only stages.
|
|
49
|
+
def apply(shas, record_origin: true, commit: true)
|
|
50
|
+
args = ['cherry-pick']
|
|
51
|
+
args << '-x' if record_origin
|
|
52
|
+
args << '--no-commit' unless commit
|
|
53
|
+
run(*args, *shas)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def continue = run('cherry-pick', '--continue')
|
|
57
|
+
|
|
58
|
+
def abort = run('cherry-pick', '--abort')
|
|
59
|
+
|
|
60
|
+
def skip = run('cherry-pick', '--skip')
|
|
61
|
+
|
|
62
|
+
# True while a cherry-pick waits for the user (conflicts or a stopped sequence)
|
|
63
|
+
def in_progress?
|
|
64
|
+
ref?('CHERRY_PICK_HEAD') || File.exist?(todo_path)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def progress
|
|
68
|
+
Progress.new(conflicted: conflicted_files, remaining: remaining, current: current)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def parse(record)
|
|
74
|
+
line, *rest = record.split("\n", 2)
|
|
75
|
+
mark, sha, subject, author, date = line.split(SEPARATOR, 5)
|
|
76
|
+
files, insertions, deletions = stat(rest.first.to_s)
|
|
77
|
+
Candidate.new(sha: sha, subject: subject.to_s, author: author, date: date,
|
|
78
|
+
applied: mark == '=', files: files, insertions: insertions,
|
|
79
|
+
deletions: deletions)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stat(text)
|
|
83
|
+
[text[/(\d+) files? changed/, 1], text[/(\d+) insertions?\(\+\)/, 1],
|
|
84
|
+
text[/(\d+) deletions?\(-\)/, 1]].map(&:to_i)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def conflicted_files
|
|
88
|
+
stdout, _stderr, success = @bash.capture('git', 'diff', '--name-only', '--diff-filter=U',
|
|
89
|
+
'-z')
|
|
90
|
+
success ? stdout.split("\0") : []
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Commits still to apply after the one that stopped, as full shas
|
|
94
|
+
def remaining
|
|
95
|
+
return [] unless File.exist?(todo_path)
|
|
96
|
+
|
|
97
|
+
shas = File.readlines(todo_path, chomp: true).filter_map { |line| line[/\Apick (\h+)/, 1] }
|
|
98
|
+
shas.filter_map { |sha| full_sha(sha) }.reject { |sha| sha == current }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def current
|
|
102
|
+
full_sha('CHERRY_PICK_HEAD')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def full_sha(ref)
|
|
106
|
+
stdout, _stderr, success = @bash.capture('git', 'rev-parse', '--verify', '--quiet',
|
|
107
|
+
"#{ref}^{commit}")
|
|
108
|
+
success ? stdout.strip : nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def ref?(ref)
|
|
112
|
+
_stdout, _stderr, success = @bash.capture('git', 'rev-parse', '--verify', '--quiet', ref)
|
|
113
|
+
success
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def todo_path
|
|
117
|
+
stdout, _stderr, success = @bash.capture('git', 'rev-parse', '--path-format=absolute',
|
|
118
|
+
'--git-path', 'sequencer/todo')
|
|
119
|
+
success ? stdout.strip : ''
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def run(*)
|
|
123
|
+
stdout, stderr, success = @bash.capture('git', *, env: ENV_NO_EDITOR)
|
|
124
|
+
Result.new(success: success,
|
|
125
|
+
output: [stdout, stderr].map(&:strip).reject(&:empty?).join("\n"))
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
# Decides what `clean` and `wt clean` would remove. Pure: it reads records and settings, runs
|
|
5
|
+
# no git command, so every rule can be tested without a repository.
|
|
6
|
+
module Cleanup
|
|
7
|
+
CRITERIA = %i[merged gone stale agent].freeze
|
|
8
|
+
DEFAULT_CRITERIA = %i[merged gone].freeze
|
|
9
|
+
|
|
10
|
+
# A record chosen for removal and why. `item` is a Branch or a Worktree.
|
|
11
|
+
Candidate = Data.define(:item, :reasons)
|
|
12
|
+
|
|
13
|
+
# Which criteria are active. `stale_days` applies to :stale.
|
|
14
|
+
Rules = Data.define(:criteria, :stale_days, :now) do
|
|
15
|
+
def reasons(branch)
|
|
16
|
+
criteria.filter_map { |criterion| label(criterion) if match?(criterion, branch) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def match?(criterion, branch)
|
|
22
|
+
case criterion
|
|
23
|
+
when :merged then branch.merged && !branch.default?
|
|
24
|
+
when :gone then branch.upstream_gone?
|
|
25
|
+
when :stale then branch.stale?(stale_days, now: now)
|
|
26
|
+
when :agent then branch.owner == 'agent'
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def label(criterion)
|
|
31
|
+
{ merged: 'merged', gone: 'upstream gone', agent: 'agent-created',
|
|
32
|
+
stale: "no commits for #{stale_days} days" }.fetch(criterion)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Branches that match at least one criterion and are not protected, each with its reasons
|
|
39
|
+
def branches(branches, rules:, protection:)
|
|
40
|
+
branches.reject { |branch| protection.protected?(branch) }.filter_map do |branch|
|
|
41
|
+
reasons = rules.reasons(branch)
|
|
42
|
+
Candidate.new(item: branch, reasons: reasons) if reasons.any?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Worktrees whose unprotected branch matches, as [chosen, skipped]. `blocker` is called with
|
|
47
|
+
# a worktree and returns why it may not be removed, or nil. A blocked match is skipped with
|
|
48
|
+
# that reason instead of being chosen.
|
|
49
|
+
def worktrees(worktrees, branches:, rules:, protection:, blocker:)
|
|
50
|
+
matched = matching(worktrees, branches, rules, protection)
|
|
51
|
+
chosen, skipped = matched.partition { |candidate| blocker.call(candidate.item).nil? }
|
|
52
|
+
[chosen, skipped.map { |candidate| skip(candidate.item, blocker.call(candidate.item)) }]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def matching(worktrees, branches, rules, protection)
|
|
56
|
+
by_name = branches.to_h { |branch| [branch.name, branch] }
|
|
57
|
+
worktrees.filter_map do |worktree|
|
|
58
|
+
branch = by_name[worktree.branch]
|
|
59
|
+
next if branch.nil? || protection.protected?(branch)
|
|
60
|
+
|
|
61
|
+
reasons = rules.reasons(branch)
|
|
62
|
+
Candidate.new(item: worktree, reasons: reasons) if reasons.any?
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def skip(worktree, reason)
|
|
67
|
+
{ path: worktree.path, branch: worktree.branch, reason: reason }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/gitflash/cli.rb
CHANGED
|
@@ -43,6 +43,27 @@ module Gitflash
|
|
|
43
43
|
run_command('delete', Commands::Delete, *branches)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
desc 'mark [BRANCH...]', descriptions.mark.short
|
|
47
|
+
long_desc descriptions.mark.long
|
|
48
|
+
option :owner, type: :string, enum: Ownership::OWNERS, desc: 'agent (default) or human'
|
|
49
|
+
option :clear, type: :boolean, default: false, desc: 'Remove the mark'
|
|
50
|
+
def mark(*branches)
|
|
51
|
+
run_command('mark', Commands::Mark, *branches)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
desc 'clean', descriptions.clean.short
|
|
55
|
+
long_desc descriptions.clean.long
|
|
56
|
+
option :merged, type: :boolean, desc: 'Branches merged into the default branch'
|
|
57
|
+
option :gone, type: :boolean, desc: 'Branches whose upstream branch was deleted'
|
|
58
|
+
option :stale, type: :string, banner: 'DAYS', lazy_default: '',
|
|
59
|
+
desc: 'Branches without commits for DAYS days (default: stale_days setting)'
|
|
60
|
+
option :agent, type: :boolean, desc: 'Branches created by an agent'
|
|
61
|
+
option :force, type: :boolean, default: false,
|
|
62
|
+
desc: 'Also delete selected branches that have unmerged changes'
|
|
63
|
+
def clean
|
|
64
|
+
run_command('clean', Commands::Clean)
|
|
65
|
+
end
|
|
66
|
+
|
|
46
67
|
desc 'reset [COMMIT]', descriptions.reset.short
|
|
47
68
|
long_desc descriptions.reset.long
|
|
48
69
|
option :hard, type: :boolean, default: false, desc: 'Discard all current changes'
|
|
@@ -81,6 +102,22 @@ module Gitflash
|
|
|
81
102
|
desc 'hook SUBCOMMAND', 'Agent hooks: claude (run the hook), install (register it)'
|
|
82
103
|
subcommand 'hook', HookCli
|
|
83
104
|
|
|
105
|
+
desc 'pick [SOURCE] [SHA...]', descriptions.pick.short
|
|
106
|
+
long_desc descriptions.pick.long
|
|
107
|
+
option :list, type: :boolean, desc: 'List the commits of SOURCE that are not on this branch yet'
|
|
108
|
+
option :commit, type: :boolean, default: true,
|
|
109
|
+
desc: 'Create a commit per pick; --no-commit only stages the changes'
|
|
110
|
+
option :continue, type: :boolean, desc: 'Continue after resolving conflicts'
|
|
111
|
+
option :skip, type: :boolean, desc: 'Skip the commit that conflicts'
|
|
112
|
+
option :abort, type: :boolean, desc: 'Give up and return to the state before the pick'
|
|
113
|
+
def pick(source = nil, *shas)
|
|
114
|
+
run_command('pick', Commands::Pick.command_for(options), *[source, *shas].compact)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
desc 'worktree SUBCOMMAND', 'Worktrees for parallel agent work (alias: wt)'
|
|
118
|
+
subcommand 'worktree', WorktreeCli
|
|
119
|
+
map 'wt' => :worktree
|
|
120
|
+
|
|
84
121
|
desc 'schema', 'Print the JSON Schema of the --json output'
|
|
85
122
|
def schema
|
|
86
123
|
puts File.read(SCHEMA_PATH)
|
|
@@ -26,13 +26,19 @@ module Gitflash
|
|
|
26
26
|
raise Error.new("git #{command} failed:\n#{result.output}", code: 'git_failed')
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def config
|
|
30
|
+
@config ||= Gitflash::Config.load(root: repo.main_root)
|
|
31
|
+
end
|
|
32
|
+
|
|
29
33
|
def snapshots
|
|
30
34
|
@snapshots ||= Gitflash::Snapshots.new
|
|
31
35
|
end
|
|
32
36
|
|
|
33
|
-
# Saves the state a change is about to modify, so `gitflash undo` can restore it
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
# Saves the state a change is about to modify, so `gitflash undo` can restore it.
|
|
38
|
+
# `dir` saves the HEAD and files of another worktree instead of the current one.
|
|
39
|
+
def take_snapshot(reason, scope:, branches: nil, dir: nil)
|
|
40
|
+
store = dir ? Gitflash::Snapshots.new(bash: Git::InDirectory.new(dir)) : snapshots
|
|
41
|
+
store.create(reason: reason, scope: scope, branches: branches)
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
def planned(plan, text)
|
|
@@ -52,6 +52,7 @@ module Gitflash
|
|
|
52
52
|
'default' => branch.default?,
|
|
53
53
|
'merged' => branch.merged && !branch.default?,
|
|
54
54
|
'gone' => branch.upstream_gone?,
|
|
55
|
+
'agent' => branch.owner == 'agent',
|
|
55
56
|
"ahead #{branch.ahead}" => branch.ahead.positive?,
|
|
56
57
|
"behind #{branch.behind}" => branch.behind.positive?
|
|
57
58
|
}.select { |_label, shown| shown }.keys.join(', ')
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Commands
|
|
5
|
+
# Deletes the branches that are no longer needed: merged ones and ones whose upstream is
|
|
6
|
+
# gone by default, plus stale or agent-created ones when asked. Protected branches and
|
|
7
|
+
# branches checked out in a worktree are never selected. Unmerged branches are kept unless
|
|
8
|
+
# --force is given. Plan, confirmation, snapshot and result work as in `delete`; each chosen
|
|
9
|
+
# branch carries the reasons it was chosen.
|
|
10
|
+
class Clean < Delete
|
|
11
|
+
include CleanCriteria
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
branches = repo.branches(merged_status: true)
|
|
15
|
+
select(branches)
|
|
16
|
+
return nothing_to_clean if @reasons.empty?
|
|
17
|
+
|
|
18
|
+
delete(@reasons.keys, branches.to_h { |branch| [branch.name, branch.sha] })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Sets @reasons (chosen branch => why) and @skipped (matches kept for being unmerged)
|
|
24
|
+
def select(branches)
|
|
25
|
+
candidates = Cleanup.branches(branches, rules: rules, protection: protection)
|
|
26
|
+
chosen, kept = candidates.partition { |candidate| force? || candidate.item.merged }
|
|
27
|
+
@reasons = chosen.to_h { |candidate| [candidate.item.name, candidate.reasons] }
|
|
28
|
+
@skipped = kept.map { |candidate| skipped_row(candidate) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_plan(names)
|
|
32
|
+
super.merge(reasons: @reasons.slice(*names), skipped: @skipped)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def skipped_row(candidate)
|
|
36
|
+
{ branch: candidate.item.name, reason: 'has unmerged changes (use --force)' }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def nothing_to_clean
|
|
40
|
+
plan = { branches: [], force: force?, reasons: {}, skipped: @skipped }
|
|
41
|
+
ui.report(status: 'noop', plan: plan, result: { deleted: [], failed: [] },
|
|
42
|
+
text: @skipped.empty? ? 'Nothing to clean' : skipped_text)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def skipped_text
|
|
46
|
+
lines = @skipped.map { |row| "Kept #{row[:branch]}: #{row[:reason]}" }
|
|
47
|
+
(['Nothing to clean.'] + lines).join("\n")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Commands
|
|
5
|
+
# The selection options shared by `clean` and `wt clean`: --merged, --gone, --stale [DAYS]
|
|
6
|
+
# and --agent. Without any of them merged and gone branches are selected.
|
|
7
|
+
module CleanCriteria
|
|
8
|
+
FLAGS = %i[merged gone stale agent].freeze
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def rules
|
|
13
|
+
given = FLAGS.select { |flag| options[flag] }
|
|
14
|
+
Cleanup::Rules.new(criteria: given.empty? ? Cleanup::DEFAULT_CRITERIA : given,
|
|
15
|
+
stale_days: stale_days, now: Time.now)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def stale_days
|
|
19
|
+
value = options[:stale]
|
|
20
|
+
return config.stale_days if value.nil? || value.to_s.empty?
|
|
21
|
+
|
|
22
|
+
days = Integer(value, exception: false)
|
|
23
|
+
return days if days&.positive?
|
|
24
|
+
|
|
25
|
+
usage_error!('invalid_options', "--stale needs a positive number of days, got '#{value}'")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -6,8 +6,6 @@ module Gitflash
|
|
|
6
6
|
# Uses `git branch -d` (merged branches only) unless --force is given.
|
|
7
7
|
# The result lists each deleted branch with its last commit, so it can be recreated.
|
|
8
8
|
class Delete < Base
|
|
9
|
-
PROTECTED_NAMES = %w[main master].freeze
|
|
10
|
-
|
|
11
9
|
def call(*names)
|
|
12
10
|
branches = repo.branches
|
|
13
11
|
candidates = branches.reject { |branch| protected?(branch) }.map(&:name)
|
|
@@ -34,8 +32,16 @@ module Gitflash
|
|
|
34
32
|
options[:force] ? true : false
|
|
35
33
|
end
|
|
36
34
|
|
|
35
|
+
def protection
|
|
36
|
+
@protection ||= Protection.new(config: config, checked_out: checked_out)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def checked_out
|
|
40
|
+
Worktrees.new(repo: repo).list.filter_map(&:branch)
|
|
41
|
+
end
|
|
42
|
+
|
|
37
43
|
def protected?(branch)
|
|
38
|
-
|
|
44
|
+
protection.protected?(branch)
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
def validate!(names, branches)
|
|
@@ -48,11 +54,12 @@ module Gitflash
|
|
|
48
54
|
|
|
49
55
|
usage_error!('protected_branch',
|
|
50
56
|
"Refusing to delete protected branches: #{refused.join(', ')} " \
|
|
51
|
-
'(current, default, main
|
|
57
|
+
'(current, default, main, master, `protected` in .gitflash.yml, ' \
|
|
58
|
+
'checked out in a worktree)')
|
|
52
59
|
end
|
|
53
60
|
|
|
54
61
|
def delete(names, shas)
|
|
55
|
-
plan =
|
|
62
|
+
plan = build_plan(names)
|
|
56
63
|
return planned(plan, "Would delete:\n#{bullets(names)}") if ui.dry_run?
|
|
57
64
|
return cancelled(plan) unless ui.confirm?(summary(names), plan: plan)
|
|
58
65
|
|
|
@@ -62,6 +69,11 @@ module Gitflash
|
|
|
62
69
|
report_results(plan, outcomes, shas, snapshot)
|
|
63
70
|
end
|
|
64
71
|
|
|
72
|
+
# The plan reported and returned in JSON; `clean` adds why each branch was chosen
|
|
73
|
+
def build_plan(names)
|
|
74
|
+
{ branches: names, force: force? }
|
|
75
|
+
end
|
|
76
|
+
|
|
65
77
|
def summary(names)
|
|
66
78
|
mode = force? ? 'even if they have unmerged changes' : 'if they are fully merged'
|
|
67
79
|
"You are about to delete the following branches #{mode}:\n\n#{bullets(names)}"
|