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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +26 -2
  4. data/command_descriptions.yml +76 -0
  5. data/lib/gitflash/branch.rb +7 -2
  6. data/lib/gitflash/cherry_pick.rb +128 -0
  7. data/lib/gitflash/cleanup.rb +70 -0
  8. data/lib/gitflash/cli.rb +37 -0
  9. data/lib/gitflash/commands/base.rb +9 -3
  10. data/lib/gitflash/commands/branches.rb +1 -0
  11. data/lib/gitflash/commands/clean.rb +51 -0
  12. data/lib/gitflash/commands/clean_criteria.rb +29 -0
  13. data/lib/gitflash/commands/delete.rb +17 -5
  14. data/lib/gitflash/commands/hook_install.rb +17 -9
  15. data/lib/gitflash/commands/hook_status.rb +24 -6
  16. data/lib/gitflash/commands/mark.rb +85 -0
  17. data/lib/gitflash/commands/pick.rb +131 -0
  18. data/lib/gitflash/commands/pick_control.rb +67 -0
  19. data/lib/gitflash/commands/pick_outcome.rb +52 -0
  20. data/lib/gitflash/commands/removal_report.rb +65 -0
  21. data/lib/gitflash/commands/undo.rb +38 -6
  22. data/lib/gitflash/commands/worktree_add.rb +98 -0
  23. data/lib/gitflash/commands/worktree_clean.rb +49 -0
  24. data/lib/gitflash/commands/worktree_command.rb +28 -0
  25. data/lib/gitflash/commands/worktree_list.rb +48 -0
  26. data/lib/gitflash/commands/worktree_lock.rb +54 -0
  27. data/lib/gitflash/commands/worktree_move.rb +47 -0
  28. data/lib/gitflash/commands/worktree_prune.rb +41 -0
  29. data/lib/gitflash/commands/worktree_remove.rb +119 -0
  30. data/lib/gitflash/commands/worktree_unlock.rb +12 -0
  31. data/lib/gitflash/config.rb +95 -0
  32. data/lib/gitflash/config_keys.rb +19 -0
  33. data/lib/gitflash/git/in_directory.rb +29 -0
  34. data/lib/gitflash/hook/branch_marker.rb +52 -0
  35. data/lib/gitflash/hook/claude.rb +23 -3
  36. data/lib/gitflash/hook/command_parser.rb +29 -9
  37. data/lib/gitflash/hook/creations.rb +85 -0
  38. data/lib/gitflash/hook/settings.rb +18 -5
  39. data/lib/gitflash/hook_cli.rb +12 -4
  40. data/lib/gitflash/ownership.rb +59 -0
  41. data/lib/gitflash/protection.rb +19 -0
  42. data/lib/gitflash/repo.rb +2 -3
  43. data/lib/gitflash/restore.rb +8 -7
  44. data/lib/gitflash/state_capture.rb +2 -2
  45. data/lib/gitflash/version.rb +1 -1
  46. data/lib/gitflash/worktree.rb +32 -0
  47. data/lib/gitflash/worktree_cli.rb +115 -0
  48. data/lib/gitflash/worktree_revival.rb +55 -0
  49. data/lib/gitflash/worktrees.rb +147 -0
  50. data/schema/v1.json +1050 -2
  51. metadata +30 -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: e8466fec203a9fdd7ac08e721fb0bb624fb8f486e68f9b369660e5d369e717ad
4
+ data.tar.gz: 53243a2e0dc1d5877bd5541999fec636e89ac882b9af78d84790ef32be3e12cb
5
5
  SHA512:
6
- metadata.gz: d7f50c47a77e7efe0410f287dfe6c7a447187300c3974512aeb17a52806bc4e139148a45f9499118cc6286c7a636f4209f476eea8e3e0eb23cb13a5c75eac3a0
7
- data.tar.gz: 271acb28eabeb2ffa6efb6e9bc9449d90dc02ab650c8d57f8f54b1330bc164f921f7bd18b9ebdc7db0094e07347e05a631708f42c4e9eff4b09d73607bf3d09f
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 |
@@ -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.
@@ -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
- def take_snapshot(reason, scope:, branches: nil)
35
- snapshots.create(reason: reason, scope: scope, branches: branches)
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
- branch.current? || branch.default? || PROTECTED_NAMES.include?(branch.name)
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 and master are protected)')
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 = { branches: names, force: force? }
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)}"