gitflash 0.5.1 → 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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +69 -2
  4. data/bin/gitflash +10 -0
  5. data/command_descriptions.yml +99 -0
  6. data/lib/gitflash/branch.rb +14 -2
  7. data/lib/gitflash/cherry_pick.rb +128 -0
  8. data/lib/gitflash/cleanup.rb +70 -0
  9. data/lib/gitflash/cli.rb +72 -21
  10. data/lib/gitflash/command_runner.rb +23 -0
  11. data/lib/gitflash/commands/base.rb +15 -0
  12. data/lib/gitflash/commands/branches.rb +1 -0
  13. data/lib/gitflash/commands/checkout.rb +2 -1
  14. data/lib/gitflash/commands/clean.rb +51 -0
  15. data/lib/gitflash/commands/clean_criteria.rb +29 -0
  16. data/lib/gitflash/commands/delete.rb +23 -8
  17. data/lib/gitflash/commands/gc.rb +41 -0
  18. data/lib/gitflash/commands/hook_install.rb +77 -0
  19. data/lib/gitflash/commands/hook_status.rb +80 -0
  20. data/lib/gitflash/commands/mark.rb +85 -0
  21. data/lib/gitflash/commands/pick.rb +131 -0
  22. data/lib/gitflash/commands/pick_control.rb +67 -0
  23. data/lib/gitflash/commands/pick_outcome.rb +52 -0
  24. data/lib/gitflash/commands/removal_report.rb +65 -0
  25. data/lib/gitflash/commands/reset.rb +7 -1
  26. data/lib/gitflash/commands/snapshot_create.rb +30 -0
  27. data/lib/gitflash/commands/snapshot_list.rb +20 -0
  28. data/lib/gitflash/commands/undo.rb +131 -0
  29. data/lib/gitflash/commands/worktree_add.rb +98 -0
  30. data/lib/gitflash/commands/worktree_clean.rb +49 -0
  31. data/lib/gitflash/commands/worktree_command.rb +28 -0
  32. data/lib/gitflash/commands/worktree_list.rb +48 -0
  33. data/lib/gitflash/commands/worktree_lock.rb +54 -0
  34. data/lib/gitflash/commands/worktree_move.rb +47 -0
  35. data/lib/gitflash/commands/worktree_prune.rb +41 -0
  36. data/lib/gitflash/commands/worktree_remove.rb +119 -0
  37. data/lib/gitflash/commands/worktree_unlock.rb +12 -0
  38. data/lib/gitflash/config.rb +95 -0
  39. data/lib/gitflash/config_keys.rb +19 -0
  40. data/lib/gitflash/git/bash_command.rb +5 -4
  41. data/lib/gitflash/git/in_directory.rb +29 -0
  42. data/lib/gitflash/hook/branch_marker.rb +52 -0
  43. data/lib/gitflash/hook/claude.rb +126 -0
  44. data/lib/gitflash/hook/command_parser.rb +121 -0
  45. data/lib/gitflash/hook/creations.rb +85 -0
  46. data/lib/gitflash/hook/rules.rb +108 -0
  47. data/lib/gitflash/hook/settings.rb +73 -0
  48. data/lib/gitflash/hook_cli.rb +77 -0
  49. data/lib/gitflash/ownership.rb +59 -0
  50. data/lib/gitflash/protection.rb +19 -0
  51. data/lib/gitflash/repo.rb +12 -3
  52. data/lib/gitflash/restore.rb +141 -0
  53. data/lib/gitflash/snapshot.rb +47 -0
  54. data/lib/gitflash/snapshots.rb +70 -0
  55. data/lib/gitflash/state_capture.rb +129 -0
  56. data/lib/gitflash/ui.rb +7 -5
  57. data/lib/gitflash/version.rb +1 -1
  58. data/lib/gitflash/worktree.rb +32 -0
  59. data/lib/gitflash/worktree_cli.rb +115 -0
  60. data/lib/gitflash/worktree_revival.rb +55 -0
  61. data/lib/gitflash/worktrees.rb +147 -0
  62. data/schema/v1.json +1885 -102
  63. metadata +46 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86ac16bd9cd3c64cd98305a6423e67c05187040d8caba798d132a4f985ee28df
4
- data.tar.gz: 8da24e90611e20296f70ca3cadab5a77f697c97ff43cf535b9f51b1bd40a66be
3
+ metadata.gz: e8466fec203a9fdd7ac08e721fb0bb624fb8f486e68f9b369660e5d369e717ad
4
+ data.tar.gz: 53243a2e0dc1d5877bd5541999fec636e89ac882b9af78d84790ef32be3e12cb
5
5
  SHA512:
6
- metadata.gz: c573a9265e4558a9d502840cb7b65b0c07c6e6f640c74bde499ed1b4ff160fd8aeb101a9ecd95b692a2170c812d0a6afd14d8abe8629d46b8e65732a7622384c
7
- data.tar.gz: 53e55b8afcc5f32cd1b0fa238e0f7db356b4e919d57f606ebec37ee3a86539fff2dd0afaef3b5bf6f295f235e9f4452ec1b5bb3dee5dc7fd1df964cb3e6f3d85
6
+ metadata.gz: 27b78d4baac19b15a9f6bef731cc0ec3622ee5c51a2ee82ab9f20a63a5f50d6ecedb3c4f1651d0849eae970f5a66c20480272d0bf00b0284e8434f1893805901
7
+ data.tar.gz: 1c1f6e76de4e2f67fb7c1c591e9569d8578a96cad8cc9e58ff75fd9448abaa8ead4e7b96cef519d6a4a991e75299e07eaafcc8d060896b8742b9c7f4ec4b93f5
data/CHANGELOG.md CHANGED
@@ -62,3 +62,28 @@ Security fixes for names and text that come from the repository:
62
62
  - Branch and default-branch detection use full ref names. A tag named like `origin/<default>` could change the short name of `origin/HEAD`, so the default branch lost its deletion protection and its merge status; `origin/HEAD` must now point inside `refs/remotes/origin/`
63
63
  - `delete` passes `--` before the branch name; `resolve_commit` refuses references that start with a dash
64
64
  - Text output shows terminal control characters of repository-controlled text (commit subjects, git errors) as escapes such as `\x1B`, so a crafted commit subject cannot move the cursor, rewrite output or set the clipboard (OSC 52). JSON output keeps the original text
65
+
66
+ ## [0.6.0] - 2026-10-05
67
+
68
+ - Snapshots: gitflash saves branches, HEAD, tracked, staged and untracked files and stash entries before every change it makes, as git objects under `refs/gitflash/snapshots/` (the working tree is never touched; untracked files over 50 MB are skipped)
69
+ - Add `undo [SNAPSHOT]`: restores the parts a snapshot saved (the latest by default), after saving the current state so the undo can be undone
70
+ - Add `snapshot` (save on request, `--scope`, `--message`), `snapshots` (list) and `gc --older-than DAYS`
71
+ - Every `done` result of `checkout`, `delete` and `reset` includes `undo` with the snapshot id; human output ends with `Undo with: gitflash undo ID`
72
+ - Add `hook claude`: a Claude Code `PreToolUse` hook that saves a snapshot before destructive git commands an agent runs itself (`reset`, `checkout -- .`, `restore`, `clean -f`, `branch -D`, `stash drop`, `rebase`, `worktree remove --force`, ...). Modes: `snapshot` (default), `ask`, `deny`. It never blocks a command because of its own errors
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
+ - Add `hook status` to show whether the hook protects Claude Code sessions in the current directory
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
@@ -1,6 +1,6 @@
1
1
  # GitFlash
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/gitflash.svg)](https://badge.fury.io/rb/gitflash)
3
+ [![GitHub version](https://badge.fury.io/gh/Kleanthismits%2Fgit_flash.svg)](https://badge.fury.io/gh/Kleanthismits%2Fgit_flash)
4
4
 
5
5
  ⚠️ **This project is still under development**: Use at your own risk!
6
6
 
@@ -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,9 +40,73 @@ 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 |
47
+ | `gitflash undo [SNAPSHOT]` | Restore a snapshot, the latest by default: deleted branches, moved branches, HEAD, uncommitted and untracked files, dropped stashes |
48
+ | `gitflash snapshot` | Save the current state on request (`--scope`, `--message`) |
49
+ | `gitflash snapshots` | List snapshots, newest first |
50
+ | `gitflash gc` | Delete snapshots older than 30 days (`--older-than DAYS`) |
51
+ | `gitflash hook install` | Protect plain git commands run by Claude Code and mark the branches it creates (see below) |
52
+ | `gitflash hook status` | Show whether the hook protects Claude Code sessions in the current directory |
41
53
  | `gitflash schema` | Print the JSON Schema of the `--json` output |
42
54
  | `gitflash version` | Print the installed version (also `--version`, `-v`) |
43
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
+
72
+ ### Undo
73
+
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.
75
+
76
+ ```bash
77
+ gitflash delete old-feature --yes
78
+ ```
79
+
80
+ ```bash
81
+ gitflash undo --yes
82
+ ```
83
+
84
+ `gitflash undo` restores only what the snapshot saved and what differs, and saves the current state first, so an undo can itself be undone.
85
+
86
+ ### Protect plain git commands run by AI agents
87
+
88
+ Agents often run git directly. Claude Code's own checkpoints do not cover changes made by shell commands, so a `git reset --hard` or `git clean -fd` run by an agent cannot be rewound there. Install the gitflash hook once per repository:
89
+
90
+ ```bash
91
+ gitflash hook install
92
+ ```
93
+
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.
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
+
98
+ Where the hook goes, following Claude Code's own rules:
99
+
100
+ | Scope | File | Applies to |
101
+ | --- | --- | --- |
102
+ | `local` (default) | `.claude/settings.local.json` at the repository root (the main checkout's root inside a worktree) | Sessions anywhere in the repository and its worktrees |
103
+ | `project` | `.claude/settings.json` at the repository top level, to share with your team | Sessions started in that directory |
104
+ | `user` | `~/.claude/settings.json` | Every session |
105
+
106
+ Claude Code reloads settings files, so running sessions pick the hook up without a restart. Run `gitflash hook status` in the directory where you start Claude Code to check which files apply and whether the `gitflash` executable is found.
107
+
108
+ ### Scripts
109
+
44
110
  Without an argument, a command shows an interactive list. With arguments it runs directly, which also works in scripts.
45
111
 
46
112
  Options for every command:
@@ -76,6 +142,7 @@ With `--json`, every command prints one object in the same envelope, defined in
76
142
 
77
143
  - `status`: `done`, `planned` (`--dry-run`), `noop`, `cancelled`, `failed`, `confirmation_required` or `error`. `ok` is true for the first four.
78
144
  - `result` includes what you need to revert: the SHA of each deleted branch (`git branch NAME SHA`), the previous branch after `checkout`, the previous commit after `reset`.
145
+ - `undo` names the snapshot saved before the change and the command that restores it (`gitflash undo ID`).
79
146
  - `error.code` is a stable identifier such as `unknown_branch`, `protected_branch` or `confirmation_required`.
80
147
 
81
148
  ```bash
data/bin/gitflash CHANGED
@@ -1,6 +1,16 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ # Fast path for the agent hook, which runs before every shell command: when the command does
5
+ # not mention git there is nothing to do, so exit before loading the rest of gitflash.
6
+ if ARGV.first(2) == %w[hook claude]
7
+ input = $stdin.read
8
+ exit 0 unless input.include?('git')
9
+
10
+ require 'stringio'
11
+ $stdin = StringIO.new(input)
12
+ end
13
+
4
14
  require 'gitflash'
5
15
  require 'gitflash/cli'
6
16
 
@@ -46,3 +46,102 @@ reset:
46
46
  By default performs a mixed reset: your changes are kept as unstaged changes.
47
47
  Pass --soft to keep them staged, or --hard to discard them. A hard reset asks for
48
48
  confirmation; pass --yes to skip it, which is required without a terminal.
49
+
50
+ undo:
51
+ short: 'Restore a snapshot (the latest by default)'
52
+ long: |
53
+ Restores the parts of the repository a snapshot saved: deleted or moved branches,
54
+ HEAD, tracked, staged and untracked files, and dropped stash entries. Only parts that
55
+ differ are changed; branches and files created after the snapshot are left alone.
56
+
57
+ gitflash saves a snapshot before every change it makes, and the agent hook saves one
58
+ before destructive git commands. List them with `gitflash snapshots`.
59
+
60
+ The current state is saved as a new snapshot first, so an undo can itself be undone.
61
+ Asks for confirmation; pass --yes to skip it (required without a terminal) or
62
+ --dry-run to only show the plan.
63
+
64
+ snapshot:
65
+ short: 'Save the current state so it can be restored with gitflash undo'
66
+ long: |
67
+ Saves branches, HEAD, tracked, staged and untracked files (not ignored ones) and stash
68
+ entries as git objects under refs/gitflash/snapshots/. The working tree is not changed.
69
+ Untracked files larger than 50 MB are skipped and listed.
70
+
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
 
@@ -29,6 +34,12 @@ module Gitflash
29
34
  )
30
35
  end
31
36
 
37
+ # { branch name => sha } from `git for-each-ref --format=TIPS_FORMAT refs/heads/`
38
+ def self.parse_tips(output)
39
+ output.to_s.each_line(chomp: true).to_h { |line| line.split(' ', 2) }
40
+ .transform_keys { |ref| ref.delete_prefix(Repo::HEADS) }
41
+ end
42
+
32
43
  # The record's fields; the full ref name is shortened by removing refs/heads/ only, because
33
44
  # `refname:short` gives another name when a tag has the same name.
34
45
  def self.fields(line)
@@ -50,4 +61,5 @@ module Gitflash
50
61
  super.merge(last_commit_at: last_commit_at.iso8601)
51
62
  end
52
63
  end
64
+ Branch::TIPS_FORMAT = '%(refname) %(objectname)'
53
65
  end
@@ -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
@@ -5,6 +5,7 @@ require 'thor'
5
5
  module Gitflash
6
6
  class Cli < Thor
7
7
  extend Configuration::Descriptions
8
+ include CommandRunner
8
9
 
9
10
  SCHEMA_PATH = File.expand_path('../../schema/v1.json', __dir__)
10
11
 
@@ -25,13 +26,13 @@ module Gitflash
25
26
  option :gone, type: :boolean, desc: 'Branches whose upstream branch was deleted'
26
27
  option :stale, type: :numeric, banner: 'DAYS', desc: 'Branches without commits for DAYS days'
27
28
  def branches
28
- run_command(Commands::Branches)
29
+ run_command('branches', Commands::Branches)
29
30
  end
30
31
 
31
32
  desc 'checkout [BRANCH]', descriptions.checkout.short
32
33
  long_desc descriptions.checkout.long
33
34
  def checkout(branch = nil)
34
- run_command(Commands::Checkout, *[branch].compact)
35
+ run_command('checkout', Commands::Checkout, *[branch].compact)
35
36
  end
36
37
 
37
38
  desc 'delete [BRANCH...]', descriptions.delete.short
@@ -39,7 +40,28 @@ module Gitflash
39
40
  option :force, type: :boolean, default: false,
40
41
  desc: 'Delete branches even if they have unmerged changes'
41
42
  def delete(*branches)
42
- run_command(Commands::Delete, *branches)
43
+ run_command('delete', Commands::Delete, *branches)
44
+ end
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)
43
65
  end
44
66
 
45
67
  desc 'reset [COMMIT]', descriptions.reset.short
@@ -47,9 +69,55 @@ module Gitflash
47
69
  option :hard, type: :boolean, default: false, desc: 'Discard all current changes'
48
70
  option :soft, type: :boolean, default: false, desc: 'Keep changes staged'
49
71
  def reset(commit = nil)
50
- run_command(Commands::Reset, *[commit].compact)
72
+ run_command('reset', Commands::Reset, *[commit].compact)
73
+ end
74
+
75
+ desc 'undo [SNAPSHOT]', descriptions.undo.short
76
+ long_desc descriptions.undo.long
77
+ def undo(snapshot = nil)
78
+ run_command('undo', Commands::Undo, *[snapshot].compact)
51
79
  end
52
80
 
81
+ desc 'snapshots', 'List snapshots, newest first'
82
+ def snapshots
83
+ run_command('snapshots', Commands::SnapshotList)
84
+ end
85
+
86
+ desc 'snapshot', descriptions.snapshot.short
87
+ long_desc descriptions.snapshot.long
88
+ option :message, type: :string, desc: 'Why the snapshot was taken'
89
+ option :scope, type: :array, banner: 'PARTS',
90
+ desc: 'Parts to save: branches head worktree stashes (default: all)'
91
+ def snapshot
92
+ run_command('snapshot', Commands::SnapshotCreate)
93
+ end
94
+
95
+ desc 'gc', 'Delete old snapshots'
96
+ option :older_than, type: :numeric, banner: 'DAYS',
97
+ desc: 'Delete snapshots older than DAYS days (default 30)'
98
+ def gc
99
+ run_command('gc', Commands::Gc)
100
+ end
101
+
102
+ desc 'hook SUBCOMMAND', 'Agent hooks: claude (run the hook), install (register it)'
103
+ subcommand 'hook', HookCli
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
+
53
121
  desc 'schema', 'Print the JSON Schema of the --json output'
54
122
  def schema
55
123
  puts File.read(SCHEMA_PATH)
@@ -61,22 +129,5 @@ module Gitflash
61
129
  def version
62
130
  puts VERSION
63
131
  end
64
-
65
- private
66
-
67
- def run_command(command_class, *)
68
- ui = build_ui(command_class.name.split('::').last.downcase)
69
- repo = Repo.new
70
- repo.ensure_work_tree!
71
- status = command_class.new(repo: repo, ui: ui, options: options).call(*)
72
- exit(status) unless status.zero?
73
- rescue Error => e
74
- ui.error(e)
75
- exit(e.exit_code)
76
- end
77
-
78
- def build_ui(command)
79
- Ui.new(command: command, json: options[:json], yes: options[:yes], dry_run: options[:dry_run])
80
- end
81
132
  end
82
133
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Runs a command class with the parsed Thor options and turns errors into output and exit codes
5
+ module CommandRunner
6
+ private
7
+
8
+ def run_command(name, command_class, *, repo_required: true)
9
+ ui = build_ui(name)
10
+ repo = Repo.new
11
+ repo.ensure_work_tree! if repo_required
12
+ status = command_class.new(repo: repo, ui: ui, options: options).call(*)
13
+ exit(status) unless status.zero?
14
+ rescue Error => e
15
+ ui.error(e)
16
+ exit(e.exit_code)
17
+ end
18
+
19
+ def build_ui(name)
20
+ Ui.new(command: name, json: options[:json], yes: options[:yes], dry_run: options[:dry_run])
21
+ end
22
+ end
23
+ end