gitflash 0.7.0.beta1 → 0.7.0.beta3

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +17 -13
  4. data/command_descriptions.yml +25 -7
  5. data/lib/gitflash/atomic_file.rb +40 -0
  6. data/lib/gitflash/cherry_pick/log_parser.rb +63 -0
  7. data/lib/gitflash/cherry_pick.rb +10 -18
  8. data/lib/gitflash/cli.rb +28 -5
  9. data/lib/gitflash/command_runner.rb +12 -1
  10. data/lib/gitflash/commands/base.rb +3 -0
  11. data/lib/gitflash/commands/clean.rb +29 -1
  12. data/lib/gitflash/commands/gc.rb +19 -4
  13. data/lib/gitflash/commands/hook_install.rb +79 -12
  14. data/lib/gitflash/commands/mark.rb +9 -3
  15. data/lib/gitflash/commands/pick.rb +13 -1
  16. data/lib/gitflash/commands/pick_control.rb +1 -1
  17. data/lib/gitflash/commands/removal_report.rb +8 -3
  18. data/lib/gitflash/commands/snapshot_create.rb +6 -4
  19. data/lib/gitflash/commands/undo.rb +24 -11
  20. data/lib/gitflash/commands/worktree_add.rb +6 -4
  21. data/lib/gitflash/commands/worktree_command.rb +17 -3
  22. data/lib/gitflash/commands/worktree_list.rb +3 -3
  23. data/lib/gitflash/commands/worktree_lock.rb +7 -3
  24. data/lib/gitflash/commands/worktree_move.rb +9 -4
  25. data/lib/gitflash/commands/worktree_prune.rb +1 -1
  26. data/lib/gitflash/commands/worktree_remove.rb +23 -7
  27. data/lib/gitflash/config.rb +69 -16
  28. data/lib/gitflash/config_keys.rb +64 -4
  29. data/lib/gitflash/confirmation_required.rb +6 -2
  30. data/lib/gitflash/git/bash_command.rb +5 -2
  31. data/lib/gitflash/hook/branch_marker.rb +109 -6
  32. data/lib/gitflash/hook/claude.rb +45 -24
  33. data/lib/gitflash/hook/command_parser.rb +100 -59
  34. data/lib/gitflash/hook/command_words.rb +73 -0
  35. data/lib/gitflash/hook/failure.rb +29 -0
  36. data/lib/gitflash/hook/git_invocation.rb +119 -0
  37. data/lib/gitflash/hook/history_rules.rb +81 -0
  38. data/lib/gitflash/hook/losses.rb +51 -0
  39. data/lib/gitflash/hook/messages.rb +64 -0
  40. data/lib/gitflash/hook/paths.rb +47 -0
  41. data/lib/gitflash/hook/rules.rb +36 -12
  42. data/lib/gitflash/hook/settings.rb +19 -2
  43. data/lib/gitflash/hook/shell_quotes.rb +64 -0
  44. data/lib/gitflash/hook/shell_text.rb +101 -0
  45. data/lib/gitflash/hook/wrappers.rb +67 -0
  46. data/lib/gitflash/hook_cli.rb +34 -4
  47. data/lib/gitflash/protection.rb +12 -4
  48. data/lib/gitflash/repo.rb +3 -2
  49. data/lib/gitflash/snapshot.rb +9 -2
  50. data/lib/gitflash/snapshot_check.rb +60 -0
  51. data/lib/gitflash/snapshots.rb +26 -3
  52. data/lib/gitflash/state_capture.rb +26 -24
  53. data/lib/gitflash/terminal_text.rb +9 -0
  54. data/lib/gitflash/ui.rb +32 -4
  55. data/lib/gitflash/untracked_files.rb +19 -0
  56. data/lib/gitflash/usage_report.rb +66 -0
  57. data/lib/gitflash/version.rb +1 -1
  58. data/lib/gitflash/worktree.rb +4 -4
  59. data/schema/v1.json +39 -2
  60. metadata +16 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8466fec203a9fdd7ac08e721fb0bb624fb8f486e68f9b369660e5d369e717ad
4
- data.tar.gz: 53243a2e0dc1d5877bd5541999fec636e89ac882b9af78d84790ef32be3e12cb
3
+ metadata.gz: cf03db9e16e9f006dae9721b8ba1b16faf97218d98ea53768ff27b1b3c017069
4
+ data.tar.gz: a369a61204dd224d254b53b00488f5dbab9f344b1dfad49e17762caf575e8d59
5
5
  SHA512:
6
- metadata.gz: 27b78d4baac19b15a9f6bef731cc0ec3622ee5c51a2ee82ab9f20a63a5f50d6ecedb3c4f1651d0849eae970f5a66c20480272d0bf00b0284e8434f1893805901
7
- data.tar.gz: 1c1f6e76de4e2f67fb7c1c591e9569d8578a96cad8cc9e58ff75fd9448abaa8ead4e7b96cef519d6a4a991e75299e07eaafcc8d060896b8742b9c7f4ec4b93f5
6
+ metadata.gz: 66c9f53118048f0071f77952c9d59d761ced1af71bbb19f829e08b84e6fbfbf7f09f13ce440fe6cadfae97ffd9f377a540153d0caa19e1ede8aaa40d9ecfc161
7
+ data.tar.gz: e93052d61297e1e36c15a601a5bc8f6d0053145ceb3f613d45a418457321691999f400a48715cf1a7654b313cb68902854015e23ef9142a345d419b9591667e9
data/CHANGELOG.md CHANGED
@@ -87,3 +87,62 @@ Beta of the parallel agent work release. Install with `gem install gitflash --pr
87
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
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
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
112
+
113
+ ## [0.7.0.beta3] - 2026-10-25
114
+
115
+ Third beta of the parallel agent work release: the fixes of a security review of the repository (issues #34 to #54), each reviewed before merging. Install with `gem install gitflash --pre`. Needs git 2.36 or newer.
116
+
117
+ Some behaviour changes, so check scripts and agent instructions that call gitflash:
118
+
119
+ - `gc`, and `wt remove` / `wt clean` of a worktree with ignored files, are no longer confirmed by `--yes`: pass `--confirm TOKEN` with the token the refusal names
120
+ - `gc --older-than` takes a whole number of days, 1 or more
121
+ - `mark` without `--owner` marks the branch as `human` when stdin is a terminal
122
+ - `clean` and `delete` also protect `develop`, `development`, `trunk` and `release/*`
123
+ - A repository's `worktree_dir` must stay next to the repository; set other places in `~/.config/gitflash.yml`
124
+ - Usage errors exit with 2 and, with `--json`, print the JSON envelope
125
+
126
+ The changes:
127
+
128
+ - The Claude hook no longer lets a command through when it fails in `ask` or `deny` mode: an error now asks the user or blocks the command, with the reason. A snapshot that fails no longer stops `ask` mode from asking, and `cd ~unknownuser` or `git -C ~unknownuser` no longer makes the hook fail (#34)
129
+ - The Claude hook snapshots the directory Claude runs in when a `cd` or `git -C` path is decided by the shell (`cd "$(git rev-parse --show-toplevel)"`, `cd "$REPO"`, `cd -`); before, it looked for a directory with that literal name and saved nothing. It says that directory was a guess and may not be the repository the command changes, and it tells the agent about every risky command it could not snapshot, in snapshot mode too, instead of staying silent (#35)
130
+ - The Claude hook sees git commands inside shell syntax: after `if`, `then`, `do`, `while`, `!` and the like, inside `$(...)`, `<(...)` and backticks, and after a quote escaped with a backslash (`echo "a\"b"; git reset --hard`). Quotes are followed as the shell does, and a command nested deeper than three levels counts as risky instead of being skipped. Before, `deny` mode let these through and snapshot mode saved nothing (#36)
131
+ - The Claude hook sees git behind wrappers with options (`sudo -u me`, `env -i`, `/usr/bin/env`, `timeout 5`, `nice -n 5`, `xargs`, `find -exec`), expands git aliases (`-c alias.x=...` and the repository's `alias.*`, including `!` aliases), snapshots the repository that `--work-tree`, `--git-dir`, `GIT_WORK_TREE` or `GIT_DIR` names, follows `pushd`, and no longer takes a redirection such as `> out` for a branch name. When a command deletes branches it does not name, the snapshot keeps every branch instead of none (#37)
132
+ - The Claude hook snapshots before more commands that lose work: `git rm -f`, `git checkout-index -f`, `git read-tree -u --reset`, `git reflog expire` and `delete`, `git gc --prune=now`, `git prune`, `git push . :branch`, `git update-ref --stdin`, `git worktree remove` without `--force` (it still deletes ignored files), and `git clean` without `-f` when `clean.requireForce` is false (#38)
133
+ - `gc --older-than` takes a whole number of days, 1 or more. Before, `0` or a negative value selected every snapshot, including the newest, and a fraction such as `0.5` selected snapshots older than that part of a day (#39)
134
+ - `hook install` keeps the installed mode when run without `--mode`; before, running it again (as the upgrade note says) silently turned `ask` or `deny` back into `snapshot`. A weaker `--mode` now needs `--downgrade`, and the result reports `mode` and `previous_mode` (#40)
135
+ - The Claude hook no longer suggests that `gitflash undo` brings everything back: for `git clean -x`/`-X` and `git worktree remove` it names the ignored files that will be deleted (no snapshot saves them), and it names untracked files over 50 MB that a snapshot left out (#41)
136
+ - `--yes` no longer confirms a change `gitflash undo` cannot revert. `gc` (which deletes snapshots) now fails with `confirmation_required` and a `confirm_token` in the error; re-run with `--confirm TOKEN` to make that exact change, or type the token in a terminal. The token is made from the plan, so it never confirms a different change (#42)
137
+ - `wt remove` and `wt clean` no longer delete ignored files (`.env`, local databases) on `--yes` alone: when a chosen worktree has ignored files, which no snapshot saves, confirming needs `--confirm TOKEN`, as for `gc` (#43)
138
+ - `undo` without an ID restores the latest snapshot taken in the current worktree, not the newest one in the repository, which can belong to another agent working in a parallel worktree. The plan shows the snapshot's `reason` (#44)
139
+ - `clean` and `delete` also protect `develop`, `development`, `trunk` and `release/*`, so a git-flow `develop` merged into `main` is no longer deleted. When the repository has a remote but no `origin/HEAD`, `clean` warns that the default branch was guessed (#45)
140
+ - `--json` prints the envelope for every error of the commands that use it (all but `schema` and `version`, which print their own output). An unexpected error is reported as `failed` (exit 1) instead of a stack trace. A bad option or unknown command is `invalid_usage` with exit 2, as the README says, instead of Thor's text and exit 1. A damaged snapshot ref is skipped with a warning instead of breaking every command that takes a snapshot. Settings whose root is not an object, or that cannot be read, are `invalid_settings`. A path with an unknown `~user` is a usage error (#46)
141
+ - Snapshots read from refs are checked before use: one whose ref name does not match its id, or that holds a commit id that is not a full SHA, an invalid branch name or a relative worktree path, is skipped with a warning. Snapshots are ordered by the time they were taken, not by ref name, so a planted ref cannot become the default for `undo` (#48)
142
+ - A worktree argument that is one worktree's path and another worktree's branch is refused as ambiguous instead of picking whichever is listed first (#52)
143
+ - `mark` without `--owner` marks the branch as `human` when stdin is a terminal and as `agent` otherwise (agents and scripts without a terminal; a script run from a terminal should pass `--owner agent`). Before, a person running `gitflash mark` to claim a branch marked it as agent work, which `clean --agent --force` deletes (#50)
144
+ - A repository's `.gitflash.yml` can no longer place worktrees anywhere on disk: its `worktree_dir` must be a relative path that stays next to the repository (no absolute path, `~` or climbing above the repository's parent), and `worktree_dir` in `~/.config/gitflash.yml` now wins over the repository's (#47)
145
+ - New `snapshot_exclude` setting: untracked files whose path or name matches (for example `*.pem`, `.env*`) are never copied into snapshots, and are listed as `excluded_files`. The README now explains that snapshots copy every untracked file that is not ignored into git objects (#49)
146
+ - `protected` patterns accept `{a,b}` alternatives, and a pattern that could never match a branch (a space at either end, a control character, `~`, `^`, `:` or `\`, `..` outside a `{a,b}` alternative, or an empty one) is `invalid_config` instead of silently protecting nothing (#51)
147
+ - `hook install` writes the settings file in one step (a temporary file renamed into place), keeps its permissions and a dotfiles link, and refuses a `hooks` value it cannot add to. A crash while writing can no longer leave a broken settings file that Claude Code would ignore, together with your permission rules (#53)
148
+ - CI: every GitHub Action is pinned to a commit SHA, checkout no longer keeps the token in the git config, and Dependabot proposes updates to the pins (#54)
data/README.md CHANGED
@@ -24,7 +24,7 @@ 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.
27
+ Prerelease versions such as `0.7.0.beta3` are only installed when you ask for them: `gem install gitflash --pre`, or `gem 'gitflash', '0.7.0.beta3'` in a Gemfile.
28
28
 
29
29
  ## Requirements
30
30
 
@@ -38,16 +38,16 @@ Run `gitflash` inside a git repository to get a list with the available commands
38
38
  | --- | --- |
39
39
  | `gitflash branches` | List local branches with last commit, upstream status and merge status. Filter with `--merged`, `--gone` or `--stale DAYS` |
40
40
  | `gitflash checkout [BRANCH]` | Check out a branch, or pick one from a list |
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` |
41
+ | `gitflash delete [BRANCH...]` | Delete branches, or pick them from a list. The current and default branch, `main`, `master`, `develop`, `development`, `trunk` and `release/*` are protected. Unmerged branches are kept unless you pass `--force` |
42
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 |
43
+ | `gitflash mark [BRANCH...]` | Mark branches as agent or human work (default: human when stdin is a terminal, agent otherwise; a script run from a terminal should pass `--owner agent`), 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`, `develop`, `development`, `trunk`, `release/*`, `protected` in `.gitflash.yml`, branches checked out in a worktree |
45
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 |
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 |
47
+ | `gitflash undo [SNAPSHOT]` | Restore a snapshot, by default the latest one taken in this worktree: deleted branches, moved branches, HEAD, uncommitted and untracked files, dropped stashes |
48
48
  | `gitflash snapshot` | Save the current state on request (`--scope`, `--message`) |
49
49
  | `gitflash snapshots` | List snapshots, newest first |
50
- | `gitflash gc` | Delete snapshots older than 30 days (`--older-than DAYS`) |
50
+ | `gitflash gc` | Delete snapshots older than 30 days (`--older-than DAYS`, at least 1) |
51
51
  | `gitflash hook install` | Protect plain git commands run by Claude Code and mark the branches it creates (see below) |
52
52
  | `gitflash hook status` | Show whether the hook protects Claude Code sessions in the current directory |
53
53
  | `gitflash schema` | Print the JSON Schema of the `--json` output |
@@ -57,18 +57,21 @@ Run `gitflash` inside a git repository to get a list with the available commands
57
57
 
58
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
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.
60
+ `gitflash wt remove` and `gitflash wt clean` save a snapshot first, and `gitflash undo` adds the worktree back with its branch and uncommitted files. Ignored files (`.env`, `node_modules`) are not saved: the plan lists them before they are deleted, and removing a worktree that has some needs `--confirm TOKEN` (the token the refusal names) instead of `--yes`. A worktree with an untracked file over 50 MB is not removed.
61
61
 
62
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
63
 
64
- Settings live in `.gitflash.yml` in the repository and `~/.config/gitflash.yml` (the repository file wins):
64
+ Settings live in `.gitflash.yml` in the repository and `~/.config/gitflash.yml` (the repository file wins, except for `worktree_dir`, where your own file wins; a repository's `worktree_dir` must be a relative path that stays next to the repository):
65
65
 
66
66
  ```yaml
67
- protected: ["release/*"] # branch patterns clean and delete never touch
67
+ protected: ["release/*", "hotfix/{a,b}"] # branch patterns clean and delete never touch; both files add up
68
68
  stale_days: 30 # used by --stale without a number
69
- worktree_dir: "../%<repo>s.worktrees/%<branch>s"
69
+ worktree_dir: "../%<repo>s.worktrees/%<branch>s" # only these two placeholders, up to 512 characters
70
+ snapshot_exclude: ["*.pem", ".env*", "secrets"] # untracked files snapshots never copy (a directory covers all below it); both files add up
70
71
  ```
71
72
 
73
+ Snapshots copy every untracked file that is not ignored (up to 50 MB) into git objects under `refs/gitflash/snapshots/`. A stray secret that is not in `.gitignore` is copied too, and a `git push --mirror` or a `refs/*` refspec would publish it. Ignore such files, or list them in `snapshot_exclude`; files left out this way cannot be restored by `gitflash undo`.
74
+
72
75
  ### Undo
73
76
 
74
77
  Before every change it makes, gitflash saves a snapshot: branches, HEAD, tracked, staged and untracked files, and stash entries. Snapshots are plain git objects under `refs/gitflash/snapshots/`; the working tree is never touched while saving. Git's own `revert` and `reflog` cover committed work; snapshots also cover what git never recorded: uncommitted and untracked files, deleted branches (git deletes their reflog) and dropped stashes.
@@ -91,9 +94,9 @@ Agents often run git directly. Claude Code's own checkpoints do not cover change
91
94
  gitflash hook install
92
95
  ```
93
96
 
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.
97
+ 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. If the hook itself fails, `ask` mode still asks and `deny` mode blocks the command.
95
98
 
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.
99
+ 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 (the installed `--mode` is kept; a weaker mode needs `--downgrade`); `gitflash hook status` says whether branch marking is on.
97
100
 
98
101
  Where the hook goes, following Claude Code's own rules:
99
102
 
@@ -125,6 +128,7 @@ Without a terminal (for example in a script, CI or an AI coding agent), gitflash
125
128
 
126
129
  - A command that needs a list selection fails with exit code 2 and says which argument to pass.
127
130
  - A destructive action (`delete`, `reset --hard`) fails with exit code 2 and returns the plan unless you pass `--yes`.
131
+ - A change `gitflash undo` cannot revert (`gc`, which deletes snapshots, and `wt remove` or `wt clean` of a worktree with ignored files) is not covered by `--yes`: it fails with exit code 2 and names a token (with `--json`, the plan and `error.confirm_token`; `--dry-run` shows the plan as text). Re-run with `--confirm TOKEN` to make that exact change; in a terminal you type the token instead.
128
132
 
129
133
  With `--json`, every command prints one object in the same envelope, defined in [schema/v1.json](schema/v1.json) (also printed by `gitflash schema`):
130
134
 
@@ -26,7 +26,8 @@ delete:
26
26
  short: 'Delete branches'
27
27
  long: |
28
28
  Deletes the given BRANCHES. Without them, presents a list with the local branches
29
- (without the current, default, main or master branch): press `space` to choose
29
+ (without the current and default branch or main, master, develop, development, trunk and
30
+ release/*): press `space` to choose
30
31
  branches and `enter` to confirm. Without a terminal, BRANCHES are required.
31
32
 
32
33
  Branches with unmerged changes are kept unless --force is given.
@@ -48,7 +49,7 @@ reset:
48
49
  confirmation; pass --yes to skip it, which is required without a terminal.
49
50
 
50
51
  undo:
51
- short: 'Restore a snapshot (the latest by default)'
52
+ short: 'Restore a snapshot (the latest of this worktree by default)'
52
53
  long: |
53
54
  Restores the parts of the repository a snapshot saved: deleted or moved branches,
54
55
  HEAD, tracked, staged and untracked files, and dropped stash entries. Only parts that
@@ -57,6 +58,12 @@ undo:
57
58
  gitflash saves a snapshot before every change it makes, and the agent hook saves one
58
59
  before destructive git commands. List them with `gitflash snapshots`.
59
60
 
61
+ Without SNAPSHOT it restores the latest snapshot taken in this worktree. All worktrees of
62
+ a repository share the snapshots, so the newest one may belong to another agent's
63
+ worktree. To restore one of those, run `gitflash undo ID` in the worktree it was taken in;
64
+ here an ID from elsewhere works only for a snapshot that saved branches alone. The plan
65
+ shows the snapshot's reason.
66
+
60
67
  The current state is saved as a new snapshot first, so an undo can itself be undone.
61
68
  Asks for confirmation; pass --yes to skip it (required without a terminal) or
62
69
  --dry-run to only show the plan.
@@ -83,8 +90,10 @@ clean:
83
90
  --stale [DAYS] no commits for DAYS days (default: `stale_days` setting, 30)
84
91
  --agent created by an agent (marked by `gitflash wt add`)
85
92
 
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
93
+ Never selected: the current and default branch, main, master, develop, development, trunk,
94
+ release/*, `protected` branches from .gitflash.yml, and branches checked out in any
95
+ worktree. With an origin remote but no origin/HEAD the default branch is guessed, and
96
+ the plan warns about it. Unmerged branches are kept unless
88
97
  --force is given. Every chosen branch lists why it was chosen.
89
98
 
90
99
  Asks for confirmation; pass --yes to skip it (required without a terminal), or --dry-run.
@@ -98,7 +107,9 @@ worktree_remove:
98
107
 
99
108
  Each removal saves a snapshot first, including uncommitted and untracked files. Ignored
100
109
  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
110
+ them, since removal deletes them for good, and --yes is not enough to confirm that: pass
111
+ --confirm TOKEN with the token the refusal names (error.confirm_token in --json), or
112
+ type it in a terminal. A worktree with an untracked file over 50 MB
102
113
  is not removed, because the snapshot cannot hold it. `gitflash undo ID` brings the
103
114
  directory, its branch and the saved files back.
104
115
 
@@ -110,6 +121,10 @@ pick:
110
121
  long: |
111
122
  Moves fixes between branches, for example between agent branches.
112
123
 
124
+ Without SOURCE, a terminal shows a list of the other local branches to choose from (use the
125
+ arrow keys, type to search, `enter` to choose and `ctrl+c` to cancel). Without a terminal,
126
+ SOURCE is required.
127
+
113
128
  `gitflash pick SOURCE --list` lists the commits of SOURCE that are not on the current branch
114
129
  yet, oldest first, with SHA, subject, author, date and a diff summary. A commit whose change
115
130
  is already here (the same patch under another SHA) is marked `=` (`applied` in JSON) and is
@@ -138,8 +153,11 @@ mark:
138
153
  created. The mark is the git config key `branch.<name>.gitflash-owner` and disappears with
139
154
  the branch.
140
155
 
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.
156
+ Without BRANCH the current branch is marked. Without --owner the owner is `human` when you
157
+ run it in a terminal and `agent` when stdin is not a terminal (agents, CI), so a person
158
+ never marks their own branch as one `clean --agent` deletes; a script run from a terminal
159
+ should pass --owner agent; --clear removes the mark. A
160
+ branch without a mark counts as human work.
143
161
 
144
162
  `gitflash wt add` marks the branches it creates. Use `gitflash mark` for branches made with
145
163
  plain git, for example at the start of an agent session: `gitflash mark`.
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+
6
+ module Gitflash
7
+ # Replaces a file in one step: the text goes to a temporary file next to it, which is then
8
+ # renamed into place, so a crash never leaves half a file. The file keeps its permissions,
9
+ # and a symbolic link (dotfiles) is followed, so the link stays and its target changes.
10
+ module AtomicFile
11
+ module_function
12
+
13
+ def write(path, text)
14
+ path = link_target(path) if File.symlink?(path)
15
+ FileUtils.mkdir_p(File.dirname(path))
16
+ file = Tempfile.create(['.gitflash', File.extname(path)], File.dirname(path))
17
+ replace(file, path, text)
18
+ end
19
+
20
+ # Where a link points, also when that file does not exist yet: the link's directory and the
21
+ # target's directory are resolved, the file name is kept
22
+ def link_target(path)
23
+ target = File.expand_path(File.readlink(path), File.dirname(path))
24
+ return File.realpath(target) if File.exist?(target)
25
+
26
+ File.join(File.realpath(File.dirname(target)), File.basename(target))
27
+ end
28
+
29
+ def replace(file, path, text)
30
+ file.write(text)
31
+ file.flush
32
+ file.fsync
33
+ file.close
34
+ File.chmod(File.stat(path).mode & 0o7777, file.path) if File.exist?(path)
35
+ File.rename(file.path, path)
36
+ ensure
37
+ FileUtils.rm_f(file.path) if file && File.exist?(file.path)
38
+ end
39
+ end
40
+ end
@@ -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
@@ -19,9 +19,6 @@ module Gitflash
19
19
  # The state of an unfinished pick: files with conflicts and the commits still to apply
20
20
  Progress = Data.define(:conflicted, :remaining, :current)
21
21
 
22
- RECORD = "\x1e"
23
- SEPARATOR = "\x1f"
24
- FORMAT = ['%m', '%H', '%s', '%an', '%cI'].join(SEPARATOR)
25
22
  ENV_NO_EDITOR = { 'GIT_EDITOR' => 'true', 'GIT_SEQUENCE_EDITOR' => 'true' }.freeze
26
23
 
27
24
  def initialize(bash: Git::BashCommand)
@@ -38,10 +35,11 @@ module Gitflash
38
35
  # Commits of `source_ref` that are not merges, oldest first, each marked as applied when an
39
36
  # equivalent change (same patch) is already on HEAD
40
37
  def candidates(source_ref)
41
- output = @bash.exec('git', 'log', '--left-right', '--cherry-mark', '--right-only',
42
- '--no-merges', '--reverse', '--shortstat', "--format=#{RECORD}#{FORMAT}",
38
+ output = @bash.exec('git', 'log', '-z', '--left-right', '--cherry-mark', '--right-only',
39
+ '--no-merges', '--reverse', '--shortstat', "--format=#{LogParser::FORMAT}",
43
40
  "HEAD...#{source_ref}", '--')
44
- output.split(RECORD).reject { |record| record.strip.empty? }.map { |record| parse(record) }
41
+ eligible = eligible_shas(source_ref)
42
+ LogParser.parse(output).select { |candidate| eligible.include?(candidate.sha) }.uniq(&:sha)
45
43
  end
46
44
 
47
45
  # Applies the commits (full shas, in the given order) with `git cherry-pick`.
@@ -70,18 +68,12 @@ module Gitflash
70
68
 
71
69
  private
72
70
 
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)
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
85
77
  end
86
78
 
87
79
  def conflicted_files
data/lib/gitflash/cli.rb CHANGED
@@ -9,6 +9,18 @@ module Gitflash
9
9
 
10
10
  SCHEMA_PATH = File.expand_path('../../schema/v1.json', __dir__)
11
11
 
12
+ # Thor reports a bad option or argument as text with exit 1. It is a usage error, so it
13
+ # exits with 2, and with --json it is printed in the JSON envelope like every other error.
14
+ def self.start(given_args = ARGV, config = {})
15
+ config[:shell] ||= Thor::Base.shell.new
16
+ dispatch(nil, given_args.dup, nil, config)
17
+ rescue Thor::Error => e
18
+ UsageReport.new(given_args).print(e)
19
+ exit(2)
20
+ rescue Errno::EPIPE
21
+ nil
22
+ end
23
+
12
24
  def self.exit_on_failure?
13
25
  true
14
26
  end
@@ -16,7 +28,10 @@ module Gitflash
16
28
  class_option :json, type: :boolean, default: false,
17
29
  desc: 'Print JSON instead of text; never prompts'
18
30
  class_option :yes, type: :boolean, aliases: '-y', default: false,
19
- desc: 'Skip confirmation prompts'
31
+ desc: 'Skip confirmation prompts for changes gitflash undo can revert'
32
+ class_option :confirm, type: :string, banner: 'TOKEN',
33
+ desc: 'Confirm a change gitflash undo cannot revert, with the token ' \
34
+ 'from its plan'
20
35
  class_option :dry_run, type: :boolean, default: false,
21
36
  desc: 'Show what would happen without changing anything'
22
37
 
@@ -45,7 +60,8 @@ module Gitflash
45
60
 
46
61
  desc 'mark [BRANCH...]', descriptions.mark.short
47
62
  long_desc descriptions.mark.long
48
- option :owner, type: :string, enum: Ownership::OWNERS, desc: 'agent (default) or human'
63
+ option :owner, type: :string, enum: Ownership::OWNERS,
64
+ desc: 'agent or human (default: human in a terminal, agent otherwise)'
49
65
  option :clear, type: :boolean, default: false, desc: 'Remove the mark'
50
66
  def mark(*branches)
51
67
  run_command('mark', Commands::Mark, *branches)
@@ -93,13 +109,20 @@ module Gitflash
93
109
  end
94
110
 
95
111
  desc 'gc', 'Delete old snapshots'
96
- option :older_than, type: :numeric, banner: 'DAYS',
97
- desc: 'Delete snapshots older than DAYS days (default 30)'
112
+ long_desc <<~TEXT
113
+ Deletes snapshots older than --older-than DAYS (default 30). gitflash undo cannot bring a
114
+ deleted snapshot back, so --yes is not enough: without a terminal gc stops and names a
115
+ token (with --json also the plan and error.confirm_token); re-run with --confirm TOKEN to
116
+ delete exactly those snapshots. In a terminal you type the token. Pass --dry-run to see the
117
+ snapshots it would delete.
118
+ TEXT
119
+ option :older_than, type: :string, banner: 'DAYS',
120
+ desc: 'Delete snapshots older than DAYS days, at least 1 (default 30)'
98
121
  def gc
99
122
  run_command('gc', Commands::Gc)
100
123
  end
101
124
 
102
- desc 'hook SUBCOMMAND', 'Agent hooks: claude (run the hook), install (register it)'
125
+ desc 'hook SUBCOMMAND', 'Claude Code hooks: install them and check they are active'
103
126
  subcommand 'hook', HookCli
104
127
 
105
128
  desc 'pick [SOURCE] [SHA...]', descriptions.pick.short
@@ -14,10 +14,21 @@ module Gitflash
14
14
  rescue Error => e
15
15
  ui.error(e)
16
16
  exit(e.exit_code)
17
+ rescue StandardError => e
18
+ report_unexpected(ui, name, e)
19
+ end
20
+
21
+ # Anything else still gets the envelope, so a script reading --json is never left with a
22
+ # stack trace
23
+ def report_unexpected(ui, name, error)
24
+ unexpected = Error.new("#{name} failed unexpectedly: #{error.message} (#{error.class})")
25
+ ui.error(unexpected)
26
+ exit(unexpected.exit_code)
17
27
  end
18
28
 
19
29
  def build_ui(name)
20
- Ui.new(command: name, json: options[:json], yes: options[:yes], dry_run: options[:dry_run])
30
+ Ui.new(command: name, json: options[:json], yes: options[:yes], dry_run: options[:dry_run],
31
+ confirm: options[:confirm])
21
32
  end
22
33
  end
23
34
  end
@@ -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
@@ -29,7 +29,34 @@ module Gitflash
29
29
  end
30
30
 
31
31
  def build_plan(names)
32
- super.merge(reasons: @reasons.slice(*names), skipped: @skipped)
32
+ plan = super.merge(reasons: @reasons.slice(*names), skipped: @skipped)
33
+ warning ? plan.merge(warning: warning) : plan
34
+ end
35
+
36
+ def summary(names)
37
+ warning ? "#{super}\n\nNote: #{warning}" : super
38
+ end
39
+
40
+ # Without origin/HEAD, "merged" means merged into a local main or master, which may not be
41
+ # the branch the team ships from
42
+ def warning
43
+ return @warning if defined?(@warning)
44
+
45
+ @warning = if default_branch_guessed?
46
+ 'origin/HEAD is not set, so gitflash guessed the default branch ' \
47
+ "(#{repo.default_branch || 'none'}). Run `git remote set-head origin " \
48
+ '--auto` so merged branches are judged against the right one.'
49
+ end
50
+ end
51
+
52
+ # An origin remote but no origin/HEAD: the default branch came from a local main or master
53
+ def default_branch_guessed?
54
+ _out, _err, set = Git::BashCommand.capture('git', 'symbolic-ref', '--quiet',
55
+ "#{Repo::REMOTE_HEAD}HEAD")
56
+ return false if set
57
+
58
+ remotes, _err, = Git::BashCommand.capture('git', 'remote')
59
+ remotes.split.include?('origin')
33
60
  end
34
61
 
35
62
  def skipped_row(candidate)
@@ -38,6 +65,7 @@ module Gitflash
38
65
 
39
66
  def nothing_to_clean
40
67
  plan = { branches: [], force: force?, reasons: {}, skipped: @skipped }
68
+ plan[:warning] = warning if warning
41
69
  ui.report(status: 'noop', plan: plan, result: { deleted: [], failed: [] },
42
70
  text: @skipped.empty? ? 'Nothing to clean' : skipped_text)
43
71
  end
@@ -2,13 +2,14 @@
2
2
 
3
3
  module Gitflash
4
4
  module Commands
5
- # Deletes snapshots older than --older-than DAYS (default 30). Git frees their objects at
6
- # its next garbage collection.
5
+ # Deletes snapshots older than --older-than DAYS (default 30, at least 1). Git frees their
6
+ # objects at its next garbage collection. A whole number of days is required: 0 or a
7
+ # negative number would select every snapshot, including the one just taken.
7
8
  class Gc < Base
8
9
  DEFAULT_DAYS = 30
9
10
 
10
11
  def call
11
- days = options[:older_than] || DEFAULT_DAYS
12
+ days = older_than_days
12
13
  old = older_than(days)
13
14
  plan = { older_than_days: days, snapshots: old.map(&:id) }
14
15
  return nothing(plan) if old.empty?
@@ -18,9 +19,23 @@ module Gitflash
18
19
 
19
20
  private
20
21
 
22
+ def older_than_days
23
+ value = options[:older_than]
24
+ return DEFAULT_DAYS if value.nil?
25
+
26
+ days = Integer(value.to_s, 10, exception: false)
27
+ return days if days&.positive?
28
+
29
+ usage_error!('invalid_options',
30
+ "--older-than needs a whole number of days, 1 or more, got '#{value}'")
31
+ end
32
+
21
33
  def delete(plan, text)
22
34
  return planned(plan, "Would delete #{text}") if ui.dry_run?
23
- return cancelled(plan) unless ui.confirm?("You are about to delete #{text}", plan: plan)
35
+
36
+ # Deleted snapshots are gone for good: no snapshot of the snapshots is kept
37
+ summary = "You are about to delete #{text}"
38
+ return cancelled(plan) unless ui.confirm?(summary, plan: plan, irreversible: true)
24
39
 
25
40
  plan[:snapshots].each { |id| snapshots.delete(id) }
26
41
  ui.report(status: 'done', plan: plan, result: { deleted: plan[:snapshots] },