gitflash 0.6.0 → 0.7.0.beta2

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +26 -2
  4. data/command_descriptions.yml +80 -0
  5. data/lib/gitflash/branch.rb +7 -2
  6. data/lib/gitflash/cherry_pick/log_parser.rb +63 -0
  7. data/lib/gitflash/cherry_pick.rb +120 -0
  8. data/lib/gitflash/cleanup.rb +70 -0
  9. data/lib/gitflash/cli.rb +38 -1
  10. data/lib/gitflash/commands/base.rb +12 -3
  11. data/lib/gitflash/commands/branches.rb +1 -0
  12. data/lib/gitflash/commands/clean.rb +51 -0
  13. data/lib/gitflash/commands/clean_criteria.rb +29 -0
  14. data/lib/gitflash/commands/delete.rb +17 -5
  15. data/lib/gitflash/commands/hook_install.rb +31 -10
  16. data/lib/gitflash/commands/hook_status.rb +24 -6
  17. data/lib/gitflash/commands/mark.rb +85 -0
  18. data/lib/gitflash/commands/pick.rb +143 -0
  19. data/lib/gitflash/commands/pick_control.rb +67 -0
  20. data/lib/gitflash/commands/pick_outcome.rb +52 -0
  21. data/lib/gitflash/commands/removal_report.rb +70 -0
  22. data/lib/gitflash/commands/undo.rb +41 -6
  23. data/lib/gitflash/commands/worktree_add.rb +100 -0
  24. data/lib/gitflash/commands/worktree_clean.rb +49 -0
  25. data/lib/gitflash/commands/worktree_command.rb +28 -0
  26. data/lib/gitflash/commands/worktree_list.rb +48 -0
  27. data/lib/gitflash/commands/worktree_lock.rb +58 -0
  28. data/lib/gitflash/commands/worktree_move.rb +52 -0
  29. data/lib/gitflash/commands/worktree_prune.rb +41 -0
  30. data/lib/gitflash/commands/worktree_remove.rb +126 -0
  31. data/lib/gitflash/commands/worktree_unlock.rb +12 -0
  32. data/lib/gitflash/config.rb +98 -0
  33. data/lib/gitflash/config_keys.rb +31 -0
  34. data/lib/gitflash/git/bash_command.rb +5 -2
  35. data/lib/gitflash/git/in_directory.rb +29 -0
  36. data/lib/gitflash/hook/branch_marker.rb +155 -0
  37. data/lib/gitflash/hook/claude.rb +54 -18
  38. data/lib/gitflash/hook/command_parser.rb +50 -12
  39. data/lib/gitflash/hook/creations.rb +85 -0
  40. data/lib/gitflash/hook/messages.rb +38 -0
  41. data/lib/gitflash/hook/rules.rb +6 -4
  42. data/lib/gitflash/hook/settings.rb +18 -5
  43. data/lib/gitflash/hook_cli.rb +20 -5
  44. data/lib/gitflash/ownership.rb +59 -0
  45. data/lib/gitflash/protection.rb +19 -0
  46. data/lib/gitflash/repo.rb +5 -5
  47. data/lib/gitflash/restore.rb +8 -7
  48. data/lib/gitflash/state_capture.rb +10 -5
  49. data/lib/gitflash/terminal_text.rb +9 -0
  50. data/lib/gitflash/ui.rb +1 -1
  51. data/lib/gitflash/version.rb +1 -1
  52. data/lib/gitflash/worktree.rb +32 -0
  53. data/lib/gitflash/worktree_cli.rb +115 -0
  54. data/lib/gitflash/worktree_revival.rb +55 -0
  55. data/lib/gitflash/worktrees.rb +147 -0
  56. data/schema/v1.json +1050 -2
  57. metadata +32 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a9dbc4a8ff348766f4e0a7911d451f372a4ab4b355e55d9f260d453a668948f
4
- data.tar.gz: 687787d41cf614c244512ac27a105bc34cf1d75a169d6eec14f56c80a3ab1c88
3
+ metadata.gz: d5d05aaa23c964d36999d266023460edddaaaf9b6298e02143de593b583d79f9
4
+ data.tar.gz: 5112665666f18a4a91a025765a33f3b5591a4d02adad72c1512624c66d245586
5
5
  SHA512:
6
- metadata.gz: d7f50c47a77e7efe0410f287dfe6c7a447187300c3974512aeb17a52806bc4e139148a45f9499118cc6286c7a636f4209f476eea8e3e0eb23cb13a5c75eac3a0
7
- data.tar.gz: 271acb28eabeb2ffa6efb6e9bc9449d90dc02ab650c8d57f8f54b1330bc164f921f7bd18b9ebdc7db0094e07347e05a631708f42c4e9eff4b09d73607bf3d09f
6
+ metadata.gz: ec5b291e0e0d4c1ed23c540356c9c5c6c2f1c65e5c1494e0afc38d7187ae0e05861e4c524fecb37552105e12d6182387b67e615afc8fe2a716538c235b039484
7
+ data.tar.gz: f34102849614fc40763142de8a3c85c0c59fdbadd2534306e7f6c8da507fa0c4f0851c6c3ec6aa1a96a7931b56e8407691c7a48ff54d10455abff6019f526f42
data/CHANGELOG.md CHANGED
@@ -73,3 +73,39 @@ 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
90
+
91
+ ## [0.7.0.beta2] - 2026-10-08
92
+
93
+ Second beta of the parallel agent work release: the `pick` branch list, a quieter `hook claude` and a round of security fixes on top of beta1. Install with `gem install gitflash --pre`. If you installed the hook before, run `gitflash hook install` again. Needs git 2.36 or newer.
94
+
95
+ - `pick` without a source branch shows a list of the other local branches in a terminal, as `checkout` does; without a terminal the source is still required
96
+ - `hook claude` is no longer listed in `gitflash hook` help (it is run by Claude Code, not by hand; `gitflash hook help claude` still describes it) and prints a short note instead of waiting for input when run in a terminal. The `hook` line in `gitflash help` now says it installs and checks the Claude Code hooks
97
+ - Security hardening, from a CodeRabbit scan of the repository:
98
+ - Snapshots save untracked files whose names look like Git pathspec magic (`:(exclude)*`) or start or end with a space; before, such a name could drop other files from the snapshot. `Repo#toplevel` and the git readers keep whitespace at the end of a path
99
+ - Confirmation prompts show control characters in paths as escapes, as other output does
100
+ - `protected` branch patterns of the user file and the repository file add up; a repository can protect more branches but can no longer remove the ones the user protects
101
+ - `worktree_dir` may only use `%<repo>s` and `%<branch>s` and is limited to 512 characters (anything else is `invalid_config`); a format width such as `%<repo>999999999s` could exhaust memory
102
+ - `pick --continue --dry-run` only shows the plan
103
+ - `pick` reads `git log -z` with one field per line, so a commit message cannot forge entries in the list, and only commits found by `git rev-list` are offered
104
+ - The git runner never hands a lone string to a shell
105
+ - The Claude hook in `ask` mode asks also when no snapshot could be saved, notices bundled branch flags (`git branch -qD x`), `git clean -i`, and commands inside `bash -c`, `sh -c` and `eval`, and marks a branch as agent work only if it did not exist before the command
106
+ - `hook install` refuses to write `.claude` settings through a symbolic link in the repository (the user scope may be a link)
107
+ - `docs/map/_meta/check.rb` and `sync-twins.sh` do not follow links, read files over 2 MB, or print raw file names
108
+ - Output keeps a path from the repository on one line: a newline or tab in a worktree path is shown as an escape, so it cannot forge a row in the confirmation text, the list or the menus. `Repo#toplevel` and the git readers remove only git's final newline, not a carriage return that ends the path
109
+ - When a command touches several directories the hook tells Claude one `gitflash undo` command per snapshot; a failure to record branches never stops the snapshot; the record directory is refused unless it is a real directory only the user can use
110
+ - A literal backslash in a path is shown doubled, so a path with a newline and a path with the characters backslash and n stay two different choices in the `wt remove` menu
111
+ - The hook marks a branch the command deleted and created again (`git branch -D x && git switch -c x`) as agent work: the record keeps a branch's creation stamp, not only its name
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.beta2` are only installed when you ask for them: `gem install gitflash --pre`, or `gem 'gitflash', '0.7.0.beta2'` 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 (pick SOURCE from a list when you omit it): `--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; 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
+ ```
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 |
@@ -69,3 +69,83 @@ 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
+ Without SOURCE, a terminal shows a list of the other local branches to choose from (use the
114
+ arrow keys, type to search, `enter` to choose and `ctrl+c` to cancel). Without a terminal,
115
+ SOURCE is required.
116
+
117
+ `gitflash pick SOURCE --list` lists the commits of SOURCE that are not on the current branch
118
+ yet, oldest first, with SHA, subject, author, date and a diff summary. A commit whose change
119
+ is already here (the same patch under another SHA) is marked `=` (`applied` in JSON) and is
120
+ never picked. Merge commits are left out.
121
+
122
+ `gitflash pick SOURCE SHA...` applies the given commits, oldest first whatever the order
123
+ given, with `git cherry-pick -x` (the message records the origin). Without SHAs, a menu in a
124
+ terminal lets you choose. Add --no-commit to only stage the combined changes.
125
+
126
+ A snapshot is saved first and the plan asks for confirmation (pass --yes, required without a
127
+ terminal, or --dry-run). `gitflash undo` returns to the starting point.
128
+
129
+ On a conflict the pick stops and the result lists the conflicted files, the commits already
130
+ applied and the ones still waiting. Resolve the files and stage them, then run
131
+ `gitflash pick --continue`. `gitflash pick --skip` drops the commit that conflicts and
132
+ `gitflash pick --abort` returns to the state before the pick (both ask for confirmation and
133
+ save a snapshot first).
134
+
135
+ For agents: `gitflash pick SOURCE --list --json` and
136
+ `gitflash pick SOURCE SHA... --yes --json`.
137
+
138
+ mark:
139
+ short: 'Mark branches as agent or human work'
140
+ long: |
141
+ Records who created a branch, so `gitflash clean --agent` only touches branches an agent
142
+ created. The mark is the git config key `branch.<name>.gitflash-owner` and disappears with
143
+ the branch.
144
+
145
+ Without BRANCH the current branch is marked. The owner is `agent` unless --owner human is
146
+ given; --clear removes the mark. A branch without a mark counts as human work.
147
+
148
+ `gitflash wt add` marks the branches it creates. Use `gitflash mark` for branches made with
149
+ plain git, for example at the start of an agent session: `gitflash mark`.
150
+
151
+ Needs no confirmation. Pass --dry-run to see what would change, --json for the result.
@@ -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,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ class CherryPick
5
+ # Reads the output of `git log -z --shortstat --format=FORMAT` into Candidate records.
6
+ #
7
+ # One field per line: a subject or an author name cannot hold a newline, and `-z` ends each
8
+ # commit with a NUL, which text cannot hold either, so the framing is unambiguous whatever a
9
+ # commit message says. Git prints the fields of a commit, a NUL, the stat of that commit and
10
+ # the fields of the next one. So chunk 0 holds the fields of commit 0, chunk i the stat of
11
+ # commit i-1 and the fields of commit i, and the last chunk only a stat.
12
+ module LogParser
13
+ FORMAT = '%m%n%H%n%s%n%an%n%cI'
14
+ FIELDS = 5
15
+
16
+ module_function
17
+
18
+ def parse(output)
19
+ chunks = output.split("\0", -1)
20
+ stats = stats_of(chunks)
21
+ fields = chunks[0...-1].map { |chunk| chunk.lines(chomp: true).last(FIELDS) }
22
+ fields.each_with_index.filter_map do |row, index|
23
+ candidate(row, stats[index + 1]) if row.size == FIELDS
24
+ end
25
+ end
26
+
27
+ def stats_of(chunks)
28
+ last = chunks.size - 1
29
+ chunks.each_with_index.map { |chunk, index| stat_part(chunk, index == last) }
30
+ end
31
+
32
+ # The part of a chunk before the fields of the next commit; the last chunk is all stat
33
+ def stat_part(chunk, last)
34
+ last ? chunk : chunk.lines(chomp: true)[0...-FIELDS].to_a.join("\n")
35
+ end
36
+
37
+ def candidate(row, stat_text)
38
+ mark, sha, subject, author, date = row
39
+ files, insertions, deletions = stat(stat_text)
40
+ Candidate.new(sha: sha, subject: subject, author: author, date: date,
41
+ applied: mark == '=', files: files, insertions: insertions,
42
+ deletions: deletions)
43
+ end
44
+
45
+ # [files, insertions, deletions] from a line such as
46
+ # " 2 files changed, 3 insertions(+), 1 deletion(-)". Split on commas and spaces, with no
47
+ # regular expression, so a long run of digits in the text costs only its own length.
48
+ def stat(text)
49
+ line = text.to_s.lines.map(&:strip).reject(&:empty?).last.to_s
50
+ counts = { 'file' => 0, 'insertion' => 0, 'deletion' => 0 }
51
+ line.split(',').each { |part| tally(counts, part) }
52
+ counts.values
53
+ end
54
+
55
+ def tally(counts, part)
56
+ count, label = part.strip.split(' ', 2)
57
+ number = Integer(count, 10, exception: false)
58
+ kind = counts.keys.find { |name| label.to_s.start_with?(name) }
59
+ counts[kind] = number if number && kind
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,120 @@
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
+ ENV_NO_EDITOR = { 'GIT_EDITOR' => 'true', 'GIT_SEQUENCE_EDITOR' => 'true' }.freeze
23
+
24
+ def initialize(bash: Git::BashCommand)
25
+ @bash = bash
26
+ end
27
+
28
+ # The full ref of a local (or remote-tracking) branch, nil when there is none
29
+ def source_ref(name)
30
+ return nil if name.to_s.empty? || name.start_with?('-')
31
+
32
+ ["#{Repo::HEADS}#{name}", "refs/remotes/#{name}"].find { |ref| ref?(ref) }
33
+ end
34
+
35
+ # Commits of `source_ref` that are not merges, oldest first, each marked as applied when an
36
+ # equivalent change (same patch) is already on HEAD
37
+ def candidates(source_ref)
38
+ output = @bash.exec('git', 'log', '-z', '--left-right', '--cherry-mark', '--right-only',
39
+ '--no-merges', '--reverse', '--shortstat', "--format=#{LogParser::FORMAT}",
40
+ "HEAD...#{source_ref}", '--')
41
+ eligible = eligible_shas(source_ref)
42
+ LogParser.parse(output).select { |candidate| eligible.include?(candidate.sha) }.uniq(&:sha)
43
+ end
44
+
45
+ # Applies the commits (full shas, in the given order) with `git cherry-pick`.
46
+ # `record_origin` adds "(cherry picked from ...)" to each message; `commit: false` only stages.
47
+ def apply(shas, record_origin: true, commit: true)
48
+ args = ['cherry-pick']
49
+ args << '-x' if record_origin
50
+ args << '--no-commit' unless commit
51
+ run(*args, *shas)
52
+ end
53
+
54
+ def continue = run('cherry-pick', '--continue')
55
+
56
+ def abort = run('cherry-pick', '--abort')
57
+
58
+ def skip = run('cherry-pick', '--skip')
59
+
60
+ # True while a cherry-pick waits for the user (conflicts or a stopped sequence)
61
+ def in_progress?
62
+ ref?('CHERRY_PICK_HEAD') || File.exist?(todo_path)
63
+ end
64
+
65
+ def progress
66
+ Progress.new(conflicted: conflicted_files, remaining: remaining, current: current)
67
+ end
68
+
69
+ private
70
+
71
+ # The commits of the source that are not merges and not on HEAD, listed by git without any
72
+ # text a commit author controls. A commit message holding the separators of the log format
73
+ # could forge extra records; only SHAs found here become candidates.
74
+ def eligible_shas(source_ref)
75
+ @bash.exec('git', 'rev-list', '--right-only', '--no-merges', "HEAD...#{source_ref}", '--')
76
+ .lines(chomp: true).to_set
77
+ end
78
+
79
+ def conflicted_files
80
+ stdout, _stderr, success = @bash.capture('git', 'diff', '--name-only', '--diff-filter=U',
81
+ '-z')
82
+ success ? stdout.split("\0") : []
83
+ end
84
+
85
+ # Commits still to apply after the one that stopped, as full shas
86
+ def remaining
87
+ return [] unless File.exist?(todo_path)
88
+
89
+ shas = File.readlines(todo_path, chomp: true).filter_map { |line| line[/\Apick (\h+)/, 1] }
90
+ shas.filter_map { |sha| full_sha(sha) }.reject { |sha| sha == current }
91
+ end
92
+
93
+ def current
94
+ full_sha('CHERRY_PICK_HEAD')
95
+ end
96
+
97
+ def full_sha(ref)
98
+ stdout, _stderr, success = @bash.capture('git', 'rev-parse', '--verify', '--quiet',
99
+ "#{ref}^{commit}")
100
+ success ? stdout.strip : nil
101
+ end
102
+
103
+ def ref?(ref)
104
+ _stdout, _stderr, success = @bash.capture('git', 'rev-parse', '--verify', '--quiet', ref)
105
+ success
106
+ end
107
+
108
+ def todo_path
109
+ stdout, _stderr, success = @bash.capture('git', 'rev-parse', '--path-format=absolute',
110
+ '--git-path', 'sequencer/todo')
111
+ success ? stdout.strip : ''
112
+ end
113
+
114
+ def run(*)
115
+ stdout, stderr, success = @bash.capture('git', *, env: ENV_NO_EDITOR)
116
+ Result.new(success: success,
117
+ output: [stdout, stderr].map(&:strip).reject(&:empty?).join("\n"))
118
+ end
119
+ end
120
+ 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'
@@ -78,9 +99,25 @@ module Gitflash
78
99
  run_command('gc', Commands::Gc)
79
100
  end
80
101
 
81
- desc 'hook SUBCOMMAND', 'Agent hooks: claude (run the hook), install (register it)'
102
+ desc 'hook SUBCOMMAND', 'Claude Code hooks: install them and check they are active'
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)
@@ -14,6 +14,9 @@ module Gitflash
14
14
 
15
15
  attr_reader :repo, :ui, :options
16
16
 
17
+ # A path from the repository, kept on one line of the output
18
+ def shown(path) = TerminalText.line(path)
19
+
17
20
  def require_interactive!(message)
18
21
  usage_error!('input_required', message) unless ui.interactive?
19
22
  end
@@ -26,13 +29,19 @@ module Gitflash
26
29
  raise Error.new("git #{command} failed:\n#{result.output}", code: 'git_failed')
27
30
  end
28
31
 
32
+ def config
33
+ @config ||= Gitflash::Config.load(root: repo.main_root)
34
+ end
35
+
29
36
  def snapshots
30
37
  @snapshots ||= Gitflash::Snapshots.new
31
38
  end
32
39
 
33
- # Saves the state a change is about to modify, so `gitflash undo` can restore it
34
- def take_snapshot(reason, scope:, branches: nil)
35
- snapshots.create(reason: reason, scope: scope, branches: branches)
40
+ # Saves the state a change is about to modify, so `gitflash undo` can restore it.
41
+ # `dir` saves the HEAD and files of another worktree instead of the current one.
42
+ def take_snapshot(reason, scope:, branches: nil, dir: nil)
43
+ store = dir ? Gitflash::Snapshots.new(bash: Git::InDirectory.new(dir)) : snapshots
44
+ store.create(reason: reason, scope: scope, branches: branches)
36
45
  end
37
46
 
38
47
  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