gitflash 0.7.0.beta2 → 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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +15 -11
  4. data/command_descriptions.yml +21 -7
  5. data/lib/gitflash/atomic_file.rb +40 -0
  6. data/lib/gitflash/cli.rb +27 -4
  7. data/lib/gitflash/command_runner.rb +12 -1
  8. data/lib/gitflash/commands/clean.rb +29 -1
  9. data/lib/gitflash/commands/gc.rb +19 -4
  10. data/lib/gitflash/commands/hook_install.rb +66 -12
  11. data/lib/gitflash/commands/mark.rb +9 -3
  12. data/lib/gitflash/commands/snapshot_create.rb +6 -4
  13. data/lib/gitflash/commands/undo.rb +18 -8
  14. data/lib/gitflash/commands/worktree_command.rb +17 -3
  15. data/lib/gitflash/commands/worktree_remove.rb +14 -5
  16. data/lib/gitflash/config.rb +63 -13
  17. data/lib/gitflash/config_keys.rb +52 -4
  18. data/lib/gitflash/confirmation_required.rb +6 -2
  19. data/lib/gitflash/hook/claude.rb +17 -12
  20. data/lib/gitflash/hook/command_parser.rb +91 -68
  21. data/lib/gitflash/hook/command_words.rb +73 -0
  22. data/lib/gitflash/hook/failure.rb +29 -0
  23. data/lib/gitflash/hook/git_invocation.rb +119 -0
  24. data/lib/gitflash/hook/history_rules.rb +81 -0
  25. data/lib/gitflash/hook/losses.rb +51 -0
  26. data/lib/gitflash/hook/messages.rb +28 -2
  27. data/lib/gitflash/hook/paths.rb +47 -0
  28. data/lib/gitflash/hook/rules.rb +31 -9
  29. data/lib/gitflash/hook/settings.rb +19 -2
  30. data/lib/gitflash/hook/shell_quotes.rb +64 -0
  31. data/lib/gitflash/hook/shell_text.rb +101 -0
  32. data/lib/gitflash/hook/wrappers.rb +67 -0
  33. data/lib/gitflash/hook_cli.rb +27 -4
  34. data/lib/gitflash/protection.rb +12 -4
  35. data/lib/gitflash/snapshot.rb +9 -2
  36. data/lib/gitflash/snapshot_check.rb +60 -0
  37. data/lib/gitflash/snapshots.rb +26 -3
  38. data/lib/gitflash/state_capture.rb +18 -21
  39. data/lib/gitflash/ui.rb +31 -3
  40. data/lib/gitflash/untracked_files.rb +19 -0
  41. data/lib/gitflash/usage_report.rb +66 -0
  42. data/lib/gitflash/version.rb +1 -1
  43. data/schema/v1.json +39 -2
  44. metadata +14 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5d05aaa23c964d36999d266023460edddaaaf9b6298e02143de593b583d79f9
4
- data.tar.gz: 5112665666f18a4a91a025765a33f3b5591a4d02adad72c1512624c66d245586
3
+ metadata.gz: cf03db9e16e9f006dae9721b8ba1b16faf97218d98ea53768ff27b1b3c017069
4
+ data.tar.gz: a369a61204dd224d254b53b00488f5dbab9f344b1dfad49e17762caf575e8d59
5
5
  SHA512:
6
- metadata.gz: ec5b291e0e0d4c1ed23c540356c9c5c6c2f1c65e5c1494e0afc38d7187ae0e05861e4c524fecb37552105e12d6182387b67e615afc8fe2a716538c235b039484
7
- data.tar.gz: f34102849614fc40763142de8a3c85c0c59fdbadd2534306e7f6c8da507fa0c4f0851c6c3ec6aa1a96a7931b56e8407691c7a48ff54d10455abff6019f526f42
6
+ metadata.gz: 66c9f53118048f0071f77952c9d59d761ced1af71bbb19f829e08b84e6fbfbf7f09f13ce440fe6cadfae97ffd9f377a540153d0caa19e1ede8aaa40d9ecfc161
7
+ data.tar.gz: e93052d61297e1e36c15a601a5bc8f6d0053145ceb3f613d45a418457321691999f400a48715cf1a7654b313cb68902854015e23ef9142a345d419b9591667e9
data/CHANGELOG.md CHANGED
@@ -109,3 +109,40 @@ Second beta of the parallel agent work release: the `pick` branch list, a quiete
109
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
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
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.beta2` are only installed when you ask for them: `gem install gitflash --pre`, or `gem 'gitflash', '0.7.0.beta2'` 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
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, the latest by default: deleted branches, moved branches, HEAD, uncommitted and untracked files, dropped stashes |
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; both files add up
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
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
 
@@ -142,8 +153,11 @@ mark:
142
153
  created. The mark is the git config key `branch.<name>.gitflash-owner` and disappears with
143
154
  the branch.
144
155
 
145
- Without BRANCH the current branch is marked. The owner is `agent` unless --owner human is
146
- given; --clear removes the mark. A branch without a mark counts as human work.
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.
147
161
 
148
162
  `gitflash wt add` marks the branches it creates. Use `gitflash mark` for branches made with
149
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
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,8 +109,15 @@ 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
@@ -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
@@ -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] },
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'shellwords'
4
5
 
5
6
  module Gitflash
6
7
  module Commands
@@ -11,10 +12,14 @@ module Gitflash
11
12
  SCOPES = Hook::Settings::SCOPES
12
13
  MARKING_COMMAND = 'gitflash hook claude'
13
14
 
15
+ # From the weakest protection to the strongest
16
+ STRENGTH = %w[snapshot ask deny].freeze
17
+
14
18
  def call
15
19
  plan = build_plan
16
20
  data = settings.read(plan[:file])
17
21
  entry = settings.entry(data)
22
+ add_mode(plan, entry)
18
23
  marking = settings.entry(data, Hook::Claude::POST_EVENT)
19
24
  return already_installed(plan) if up_to_date?(entry, marking, plan)
20
25
  return planned(plan, "Would add the gitflash hook to #{plan[:file]}") if ui.dry_run?
@@ -32,8 +37,46 @@ module Gitflash
32
37
  scope = choice(:scope, SCOPES, 'local')
33
38
  file = settings.path(scope)
34
39
  refuse_symlink!(file) unless scope == 'user'
35
- { scope: scope, file: file, command: hook_command,
36
- marking_command: MARKING_COMMAND, applies_to: settings.applies_to(scope) }
40
+ { scope: scope, file: file, marking_command: MARKING_COMMAND,
41
+ applies_to: settings.applies_to(scope) }
42
+ end
43
+
44
+ # Without --mode the installed mode is kept, so running install again to add the
45
+ # PostToolUse hook never turns ask or deny off. A weaker --mode needs --downgrade.
46
+ def add_mode(plan, entry)
47
+ previous = entry && installed_mode(entry['command'])
48
+ mode = options[:mode] ? choice(:mode, Hook::Claude::MODES, 'snapshot') : previous
49
+ mode ||= 'snapshot'
50
+ refuse_downgrade!(previous, mode) if previous
51
+ plan[:mode] = mode
52
+ plan[:previous_mode] = previous if previous && previous != mode
53
+ plan[:command] = hook_command(mode)
54
+ end
55
+
56
+ # The mode the installed command runs in, read the way a shell splits it: `snapshot` when
57
+ # it has no --mode; a --mode gitflash does not know is refused, never read as snapshot,
58
+ # so a reinstall cannot quietly weaken it
59
+ def installed_mode(command)
60
+ words = Shellwords.split(command.to_s)
61
+ index = words.index { |word| word == '--mode' || word.start_with?('--mode=') }
62
+ return 'snapshot' unless index
63
+
64
+ mode = words[index] == '--mode' ? words[index + 1] : words[index].delete_prefix('--mode=')
65
+ return mode if Hook::Claude::MODES.include?(mode)
66
+
67
+ raise Error.new("The installed hook command `#{command}` has a mode gitflash cannot " \
68
+ 'read; fix or remove it, then install again', code: 'invalid_settings')
69
+ rescue ArgumentError
70
+ raise Error.new("The installed hook command `#{command}` cannot be parsed",
71
+ code: 'invalid_settings')
72
+ end
73
+
74
+ def refuse_downgrade!(previous, mode)
75
+ return if options[:downgrade] || STRENGTH.index(mode).to_i >= STRENGTH.index(previous).to_i
76
+
77
+ usage_error!('invalid_options',
78
+ "The installed hook runs in #{previous} mode; --mode #{mode} would protect " \
79
+ 'less. Pass --downgrade to change it anyway.')
37
80
  end
38
81
 
39
82
  # The repository decides what .claude holds, and a link there could point at the user's own
@@ -54,10 +97,9 @@ module Gitflash
54
97
  def install(data, entry, marking, plan)
55
98
  entry ? entry['command'] = plan[:command] : add_entry(data, 'PreToolUse', plan[:command])
56
99
  add_entry(data, Hook::Claude::POST_EVENT, MARKING_COMMAND) unless marking
57
- FileUtils.mkdir_p(File.dirname(plan[:file]))
58
- File.write(plan[:file], "#{JSON.pretty_generate(data)}\n")
100
+ write_atomically(plan[:file], "#{JSON.pretty_generate(data)}\n")
59
101
  ui.report(status: 'done', plan: plan, result: plan,
60
- text: "Installed the gitflash hook in #{plan[:file]}.\n" \
102
+ text: "Installed the gitflash hook in #{plan[:file]} (#{mode_text(plan)}).\n" \
61
103
  "It applies to #{plan[:applies_to]}, including running ones.")
62
104
  end
63
105
 
@@ -68,22 +110,34 @@ module Gitflash
68
110
  usage_error!('invalid_options', "Unknown #{name} '#{value}' (use #{allowed.join(', ')})")
69
111
  end
70
112
 
71
- def hook_command
72
- mode = choice(:mode, Hook::Claude::MODES, 'snapshot')
113
+ def hook_command(mode)
73
114
  mode == 'snapshot' ? 'gitflash hook claude' : "gitflash hook claude --mode #{mode}"
74
115
  end
75
116
 
117
+ def mode_text(plan)
118
+ changed = plan[:previous_mode] ? ", was #{plan[:previous_mode]}" : ''
119
+ "#{plan[:mode]} mode#{changed}"
120
+ end
121
+
76
122
  def already_installed(plan)
77
123
  ui.report(status: 'noop', plan: plan, result: plan,
78
124
  text: "Already installed in #{plan[:file]}.\nIt applies to #{plan[:applies_to]}.")
79
125
  end
80
126
 
127
+ # Claude Code ignores a settings file it cannot parse, and with it the user's own
128
+ # permission rules, so the file is replaced in one step (AtomicFile)
129
+ def write_atomically(path, text)
130
+ AtomicFile.write(path, text)
131
+ rescue SystemCallError, IOError => e
132
+ raise Error.new("Could not write #{path}: #{e.message}", code: 'invalid_settings')
133
+ end
134
+
135
+ # Settings#read has checked that `hooks` and each of its events have the shape Claude Code
136
+ # reads, so a missing event is the only one to create
81
137
  def add_entry(data, event, command)
82
- hooks = (data['hooks'] ||= {})
83
- (hooks[event] ||= []) << {
84
- 'matcher' => 'Bash',
85
- 'hooks' => [{ 'type' => 'command', 'command' => command, 'timeout' => 30 }]
86
- }
138
+ groups = ((data['hooks'] ||= {})[event] ||= [])
139
+ hook = { 'type' => 'command', 'command' => command, 'timeout' => 30 }
140
+ groups << { 'matcher' => 'Bash', 'hooks' => [hook] }
87
141
  end
88
142
  end
89
143
  end
@@ -2,8 +2,10 @@
2
2
 
3
3
  module Gitflash
4
4
  module Commands
5
- # Marks branches as agent or human work (`--owner`, default agent) or removes the mark
6
- # (`--clear`), so `gitflash clean --agent` knows which branches an agent created.
5
+ # Marks branches as agent or human work (`--owner`) or removes the mark (`--clear`), so
6
+ # `gitflash clean --agent` knows which branches an agent created. Without --owner a person
7
+ # in a terminal marks the branch as theirs (human) and an agent or script as agent work:
8
+ # `agent` is the side `clean --agent` deletes, so a person never claims it by accident.
7
9
  # Without branch names the current branch is marked. Needs no confirmation: only a git
8
10
  # config key changes, and `--clear` or another `mark` reverses it.
9
11
  class Mark < Base
@@ -32,12 +34,16 @@ module Gitflash
32
34
  usage_error!('invalid_options', 'Use either --owner or --clear, not both') if given
33
35
  return nil
34
36
  end
35
- return 'agent' if given.nil?
37
+ return default_owner if given.nil?
36
38
  return given if Ownership::OWNERS.include?(given)
37
39
 
38
40
  usage_error!('invalid_options', "Unknown owner '#{given}' (use agent or human)")
39
41
  end
40
42
 
43
+ def default_owner
44
+ $stdin.tty? ? 'human' : 'agent'
45
+ end
46
+
41
47
  def verb
42
48
  options[:clear] ? 'clear the mark of' : "mark as #{owner}"
43
49
  end
@@ -20,10 +20,12 @@ module Gitflash
20
20
  end
21
21
 
22
22
  def text(snapshot)
23
- text = "Saved snapshot #{snapshot.id}"
24
- return text if snapshot.skipped_files.empty?
25
-
26
- "#{text}\nSkipped large untracked files: #{snapshot.skipped_files.join(', ')}"
23
+ lines = ["Saved snapshot #{snapshot.id}"]
24
+ lines << "Skipped large untracked files: #{snapshot.skipped_files.join(', ')}" if
25
+ snapshot.skipped_files.any?
26
+ lines << "Left out by snapshot_exclude: #{snapshot.excluded_files.join(', ')}" if
27
+ snapshot.excluded_files.any?
28
+ lines.join("\n")
27
29
  end
28
30
  end
29
31
  end