gitflash 0.7.0.beta1 → 0.7.0.beta2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +4 -4
- data/command_descriptions.yml +4 -0
- data/lib/gitflash/cherry_pick/log_parser.rb +63 -0
- data/lib/gitflash/cherry_pick.rb +10 -18
- data/lib/gitflash/cli.rb +1 -1
- data/lib/gitflash/commands/base.rb +3 -0
- data/lib/gitflash/commands/hook_install.rb +14 -1
- data/lib/gitflash/commands/pick.rb +13 -1
- data/lib/gitflash/commands/pick_control.rb +1 -1
- data/lib/gitflash/commands/removal_report.rb +8 -3
- data/lib/gitflash/commands/undo.rb +6 -3
- data/lib/gitflash/commands/worktree_add.rb +6 -4
- data/lib/gitflash/commands/worktree_list.rb +3 -3
- data/lib/gitflash/commands/worktree_lock.rb +7 -3
- data/lib/gitflash/commands/worktree_move.rb +9 -4
- data/lib/gitflash/commands/worktree_prune.rb +1 -1
- data/lib/gitflash/commands/worktree_remove.rb +9 -2
- data/lib/gitflash/config.rb +11 -8
- data/lib/gitflash/config_keys.rb +13 -1
- data/lib/gitflash/git/bash_command.rb +5 -2
- data/lib/gitflash/hook/branch_marker.rb +109 -6
- data/lib/gitflash/hook/claude.rb +32 -16
- data/lib/gitflash/hook/command_parser.rb +24 -6
- data/lib/gitflash/hook/messages.rb +38 -0
- data/lib/gitflash/hook/rules.rb +6 -4
- data/lib/gitflash/hook_cli.rb +9 -2
- data/lib/gitflash/repo.rb +3 -2
- data/lib/gitflash/state_capture.rb +8 -3
- data/lib/gitflash/terminal_text.rb +9 -0
- data/lib/gitflash/ui.rb +1 -1
- data/lib/gitflash/version.rb +1 -1
- data/lib/gitflash/worktree.rb +4 -4
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d5d05aaa23c964d36999d266023460edddaaaf9b6298e02143de593b583d79f9
|
|
4
|
+
data.tar.gz: 5112665666f18a4a91a025765a33f3b5591a4d02adad72c1512624c66d245586
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec5b291e0e0d4c1ed23c540356c9c5c6c2f1c65e5c1494e0afc38d7187ae0e05861e4c524fecb37552105e12d6182387b67e615afc8fe2a716538c235b039484
|
|
7
|
+
data.tar.gz: f34102849614fc40763142de8a3c85c0c59fdbadd2534306e7f6c8da507fa0c4f0851c6c3ec6aa1a96a7931b56e8407691c7a48ff54d10455abff6019f526f42
|
data/CHANGELOG.md
CHANGED
|
@@ -87,3 +87,25 @@ 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
|
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.
|
|
27
|
+
Prerelease versions such as `0.7.0.beta2` are only installed when you ask for them: `gem install gitflash --pre`, or `gem 'gitflash', '0.7.0.beta2'` in a Gemfile.
|
|
28
28
|
|
|
29
29
|
## Requirements
|
|
30
30
|
|
|
@@ -43,7 +43,7 @@ Run `gitflash` inside a git repository to get a list with the available commands
|
|
|
43
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
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
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 |
|
|
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
47
|
| `gitflash undo [SNAPSHOT]` | Restore a snapshot, the latest by default: 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 |
|
|
@@ -64,9 +64,9 @@ Run `gitflash` inside a git repository to get a list with the available commands
|
|
|
64
64
|
Settings live in `.gitflash.yml` in the repository and `~/.config/gitflash.yml` (the repository file wins):
|
|
65
65
|
|
|
66
66
|
```yaml
|
|
67
|
-
protected: ["release/*"] # branch patterns clean and delete never touch
|
|
67
|
+
protected: ["release/*"] # 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
70
|
```
|
|
71
71
|
|
|
72
72
|
### Undo
|
data/command_descriptions.yml
CHANGED
|
@@ -110,6 +110,10 @@ pick:
|
|
|
110
110
|
long: |
|
|
111
111
|
Moves fixes between branches, for example between agent branches.
|
|
112
112
|
|
|
113
|
+
Without SOURCE, a terminal shows a list of the other local branches to choose from (use the
|
|
114
|
+
arrow keys, type to search, `enter` to choose and `ctrl+c` to cancel). Without a terminal,
|
|
115
|
+
SOURCE is required.
|
|
116
|
+
|
|
113
117
|
`gitflash pick SOURCE --list` lists the commits of SOURCE that are not on the current branch
|
|
114
118
|
yet, oldest first, with SHA, subject, author, date and a diff summary. A commit whose change
|
|
115
119
|
is already here (the same patch under another SHA) is marked `=` (`applied` in JSON) and is
|
|
@@ -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
|
data/lib/gitflash/cherry_pick.rb
CHANGED
|
@@ -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=#{
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
@@ -99,7 +99,7 @@ module Gitflash
|
|
|
99
99
|
run_command('gc', Commands::Gc)
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
-
desc 'hook SUBCOMMAND', '
|
|
102
|
+
desc 'hook SUBCOMMAND', 'Claude Code hooks: install them and check they are active'
|
|
103
103
|
subcommand 'hook', HookCli
|
|
104
104
|
|
|
105
105
|
desc 'pick [SOURCE] [SHA...]', descriptions.pick.short
|
|
@@ -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
|
|
@@ -30,10 +30,23 @@ module Gitflash
|
|
|
30
30
|
|
|
31
31
|
def build_plan
|
|
32
32
|
scope = choice(:scope, SCOPES, 'local')
|
|
33
|
-
|
|
33
|
+
file = settings.path(scope)
|
|
34
|
+
refuse_symlink!(file) unless scope == 'user'
|
|
35
|
+
{ scope: scope, file: file, command: hook_command,
|
|
34
36
|
marking_command: MARKING_COMMAND, applies_to: settings.applies_to(scope) }
|
|
35
37
|
end
|
|
36
38
|
|
|
39
|
+
# The repository decides what .claude holds, and a link there could point at the user's own
|
|
40
|
+
# settings; writing through it would change a scope other than the one asked for. The
|
|
41
|
+
# user scope may be a link (dotfiles) because the user owns it.
|
|
42
|
+
def refuse_symlink!(file)
|
|
43
|
+
linked = [file, File.dirname(file)].find { |path| File.symlink?(path) }
|
|
44
|
+
return unless linked
|
|
45
|
+
|
|
46
|
+
raise Error.new("#{linked} is a symbolic link; gitflash will not write settings through it",
|
|
47
|
+
code: 'invalid_settings')
|
|
48
|
+
end
|
|
49
|
+
|
|
37
50
|
def settings
|
|
38
51
|
@settings ||= Hook::Settings.new(options[:scope] == 'user' ? nil : repo)
|
|
39
52
|
end
|
|
@@ -18,8 +18,10 @@ module Gitflash
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def call(source = nil, *shas)
|
|
21
|
-
usage_error!('input_required', 'Pass the source branch: gitflash pick SOURCE') unless source
|
|
22
21
|
check_idle!
|
|
22
|
+
source ||= pick_source
|
|
23
|
+
return 0 if source.nil?
|
|
24
|
+
|
|
23
25
|
candidates = cherry_pick.candidates(source_ref(source))
|
|
24
26
|
return list(source, candidates) if options[:list]
|
|
25
27
|
|
|
@@ -31,6 +33,16 @@ module Gitflash
|
|
|
31
33
|
|
|
32
34
|
private
|
|
33
35
|
|
|
36
|
+
# The branch picked from a menu of the other local branches (as `checkout` does), nil after
|
|
37
|
+
# telling the user there is no other branch
|
|
38
|
+
def pick_source
|
|
39
|
+
require_interactive!('Pass the source branch: gitflash pick SOURCE')
|
|
40
|
+
branches = repo.branches
|
|
41
|
+
return nothing_chosen('You only have one branch!') if branches.size < 2
|
|
42
|
+
|
|
43
|
+
ui.select('Select the branch to pick commits from', branches.reject(&:current?).map(&:name))
|
|
44
|
+
end
|
|
45
|
+
|
|
34
46
|
def check_idle!
|
|
35
47
|
return unless cherry_pick.in_progress?
|
|
36
48
|
|
|
@@ -15,8 +15,8 @@ module Gitflash
|
|
|
15
15
|
usage_error!('invalid_usage', 'No cherry-pick in progress') unless cherry_pick.in_progress?
|
|
16
16
|
|
|
17
17
|
plan = build_plan(action)
|
|
18
|
-
return perform(action, plan, nil) if action == 'continue'
|
|
19
18
|
return planned(plan, "Would #{action} the cherry-pick in progress") if ui.dry_run?
|
|
19
|
+
return perform(action, plan, nil) if action == 'continue'
|
|
20
20
|
return cancelled(plan) unless ui.confirm?(summary(action), plan: plan)
|
|
21
21
|
|
|
22
22
|
perform(action, plan, take_snapshot("gitflash pick --#{action}", scope: %w[head worktree]))
|
|
@@ -21,7 +21,8 @@ module Gitflash
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def ignored_line(row)
|
|
24
|
-
"* #{row[:path]}: #{row[:ignored_count]}
|
|
24
|
+
"* #{TerminalText.line(row[:path])}: #{row[:ignored_count]} " \
|
|
25
|
+
"(#{TerminalText.line(row[:ignored].join(', '))})"
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def report_results(plan, outcomes)
|
|
@@ -56,8 +57,12 @@ module Gitflash
|
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
def results_text(result)
|
|
59
|
-
lines = result[:removed].map
|
|
60
|
-
|
|
60
|
+
lines = result[:removed].map do |row|
|
|
61
|
+
"Removed #{TerminalText.line(row[:path])} (undo: #{row[:undo]})"
|
|
62
|
+
end
|
|
63
|
+
lines += result[:failed].map do |row|
|
|
64
|
+
"Not removed #{TerminalText.line(row[:path])}: #{row[:error]}"
|
|
65
|
+
end
|
|
61
66
|
lines.join("\n")
|
|
62
67
|
end
|
|
63
68
|
end
|
|
@@ -80,8 +80,9 @@ module Gitflash
|
|
|
80
80
|
def revival_hint(plan)
|
|
81
81
|
return '' unless plan[:recreate_worktree]
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
path = TerminalText.line(plan[:recreate_worktree])
|
|
84
|
+
"\nThe worktree #{path} was added again and may be partly restored; " \
|
|
85
|
+
"remove it with: gitflash wt remove #{path} --force"
|
|
85
86
|
end
|
|
86
87
|
|
|
87
88
|
def no_snapshots
|
|
@@ -100,7 +101,9 @@ module Gitflash
|
|
|
100
101
|
|
|
101
102
|
def describe(plan)
|
|
102
103
|
lines = plan[:branches].map { |change| branch_line(change) }
|
|
103
|
-
|
|
104
|
+
if plan[:recreate_worktree]
|
|
105
|
+
lines << "* add worktree #{TerminalText.line(plan[:recreate_worktree])} again"
|
|
106
|
+
end
|
|
104
107
|
lines << head_line(plan[:head]) if plan[:head] && !plan[:recreate_worktree]
|
|
105
108
|
(lines + file_lines(plan)).join("\n")
|
|
106
109
|
end
|
|
@@ -15,7 +15,7 @@ module Gitflash
|
|
|
15
15
|
plan = build_plan(branch, source)
|
|
16
16
|
if ui.dry_run?
|
|
17
17
|
return planned(plan,
|
|
18
|
-
"Would add worktree #{plan[:path]} on #{branch} (#{source})")
|
|
18
|
+
"Would add worktree #{shown(plan[:path])} on #{branch} (#{source})")
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
add(worktrees, plan)
|
|
@@ -48,13 +48,15 @@ module Gitflash
|
|
|
48
48
|
holder = worktrees.list.find { |worktree| worktree.branch == branch }
|
|
49
49
|
return unless holder
|
|
50
50
|
|
|
51
|
-
usage_error!('invalid_usage',
|
|
51
|
+
usage_error!('invalid_usage',
|
|
52
|
+
"Branch #{branch} is already checked out at #{TerminalText.line(holder.path)}")
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
def build_plan(branch, source)
|
|
55
56
|
path = target_path(branch)
|
|
56
57
|
if File.exist?(path) && !(File.directory?(path) && Dir.empty?(path))
|
|
57
|
-
usage_error!('invalid_usage',
|
|
58
|
+
usage_error!('invalid_usage',
|
|
59
|
+
"#{TerminalText.line(path)} already exists and is not an empty directory")
|
|
58
60
|
end
|
|
59
61
|
|
|
60
62
|
{ branch: branch, path: path, source: source.to_s, from: options[:from],
|
|
@@ -86,7 +88,7 @@ module Gitflash
|
|
|
86
88
|
|
|
87
89
|
def report(plan)
|
|
88
90
|
ui.report(status: 'done', plan: plan, result: outcome(plan),
|
|
89
|
-
text: "Added worktree #{plan[:path]} on #{plan[:branch]}")
|
|
91
|
+
text: "Added worktree #{TerminalText.line(plan[:path])} on #{plan[:branch]}")
|
|
90
92
|
end
|
|
91
93
|
|
|
92
94
|
def outcome(plan)
|
|
@@ -13,13 +13,13 @@ module Gitflash
|
|
|
13
13
|
private
|
|
14
14
|
|
|
15
15
|
def table(worktrees)
|
|
16
|
-
width = worktrees.map { |worktree| worktree.path.length }.max
|
|
16
|
+
width = worktrees.map { |worktree| TerminalText.line(worktree.path).length }.max
|
|
17
17
|
worktrees.map { |worktree| row(worktree, width) }.join("\n")
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def row(worktree, width)
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
name = worktree.branch || "(detached #{worktree.head.to_s[0, 7]})"
|
|
22
|
+
[TerminalText.line(worktree.path).ljust(width), name, flags(worktree)].join(' ').rstrip
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def flags(worktree)
|
|
@@ -13,7 +13,10 @@ module Gitflash
|
|
|
13
13
|
worktree = find!(target)
|
|
14
14
|
refuse_main!(worktree, verb)
|
|
15
15
|
return noop(worktree) if worktree.locked? == lock?
|
|
16
|
-
|
|
16
|
+
if ui.dry_run?
|
|
17
|
+
return planned(plan(worktree),
|
|
18
|
+
"Would #{verb} #{TerminalText.line(worktree.path)}")
|
|
19
|
+
end
|
|
17
20
|
|
|
18
21
|
change(worktree)
|
|
19
22
|
end
|
|
@@ -32,7 +35,7 @@ module Gitflash
|
|
|
32
35
|
result = git_change(worktree)
|
|
33
36
|
git_error!("worktree #{verb}", result) unless result.success?
|
|
34
37
|
|
|
35
|
-
report('done', worktree, "#{verb.capitalize}ed #{worktree.path}")
|
|
38
|
+
report('done', worktree, "#{verb.capitalize}ed #{TerminalText.line(worktree.path)}")
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
def git_change(worktree)
|
|
@@ -42,7 +45,8 @@ module Gitflash
|
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
def noop(worktree)
|
|
45
|
-
report('noop', worktree,
|
|
48
|
+
report('noop', worktree,
|
|
49
|
+
"#{TerminalText.line(worktree.path)} is #{lock? ? 'already locked' : 'not locked'}")
|
|
46
50
|
end
|
|
47
51
|
|
|
48
52
|
def report(status, worktree, text)
|
|
@@ -14,7 +14,10 @@ module Gitflash
|
|
|
14
14
|
worktree = find!(target)
|
|
15
15
|
check!(worktree)
|
|
16
16
|
plan = { from: worktree.path, to: File.expand_path(destination), force: force? }
|
|
17
|
-
|
|
17
|
+
if ui.dry_run?
|
|
18
|
+
return planned(plan,
|
|
19
|
+
"Would move #{shown(plan[:from])} to #{shown(plan[:to])}")
|
|
20
|
+
end
|
|
18
21
|
|
|
19
22
|
move(plan)
|
|
20
23
|
end
|
|
@@ -28,11 +31,13 @@ module Gitflash
|
|
|
28
31
|
def check!(worktree)
|
|
29
32
|
refuse_main!(worktree, 'move')
|
|
30
33
|
if worktree.path == repo.toplevel
|
|
31
|
-
usage_error!('protected_worktree',
|
|
34
|
+
usage_error!('protected_worktree',
|
|
35
|
+
"#{TerminalText.line(worktree.path)} is the current worktree")
|
|
32
36
|
end
|
|
33
37
|
return unless worktree.locked? && !force?
|
|
34
38
|
|
|
35
|
-
usage_error!('protected_worktree',
|
|
39
|
+
usage_error!('protected_worktree',
|
|
40
|
+
"#{TerminalText.line(worktree.path)} is locked (use --force)")
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
def move(plan)
|
|
@@ -40,7 +45,7 @@ module Gitflash
|
|
|
40
45
|
git_error!('worktree move', result) unless result.success?
|
|
41
46
|
|
|
42
47
|
ui.report(status: 'done', plan: plan, result: plan.slice(:from, :to),
|
|
43
|
-
text: "Moved #{plan[:from]} to #{plan[:to]}")
|
|
48
|
+
text: "Moved #{shown(plan[:from])} to #{shown(plan[:to])}")
|
|
44
49
|
end
|
|
45
50
|
end
|
|
46
51
|
end
|
|
@@ -24,13 +24,18 @@ module Gitflash
|
|
|
24
24
|
def pick(worktrees)
|
|
25
25
|
require_interactive!('Pass the worktrees to remove: gitflash wt remove PATH_OR_BRANCH...')
|
|
26
26
|
choices = worktrees.reject { |worktree| worktree.main? || current?(worktree) }
|
|
27
|
-
.to_h { |wt| [
|
|
27
|
+
.to_h { |wt| [menu_label(wt), wt.path] }
|
|
28
28
|
return report_nothing('No worktrees available to remove') if choices.empty?
|
|
29
29
|
|
|
30
30
|
picked = ui.multi_select('Select worktrees to remove', choices)
|
|
31
31
|
picked.empty? ? report_nothing('No worktrees selected') : picked
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Paths come from the repository, so control characters in them are shown as escapes
|
|
35
|
+
def menu_label(worktree)
|
|
36
|
+
TerminalText.line("#{worktree.branch || '(detached)'} #{worktree.path}")
|
|
37
|
+
end
|
|
38
|
+
|
|
34
39
|
def force?
|
|
35
40
|
options[:force] ? true : false
|
|
36
41
|
end
|
|
@@ -107,7 +112,9 @@ module Gitflash
|
|
|
107
112
|
end
|
|
108
113
|
|
|
109
114
|
def bullets(chosen)
|
|
110
|
-
chosen.map { |worktree|
|
|
115
|
+
chosen.map { |worktree|
|
|
116
|
+
"* #{TerminalText.line(worktree.path)} (#{worktree.branch || 'detached'})"
|
|
117
|
+
}.join("\n")
|
|
111
118
|
end
|
|
112
119
|
|
|
113
120
|
def report_nothing(text)
|
data/lib/gitflash/config.rb
CHANGED
|
@@ -8,13 +8,15 @@ module Gitflash
|
|
|
8
8
|
# Command-line flags are applied by the commands themselves and win over all of these.
|
|
9
9
|
# An unknown key or a value of the wrong type raises a UsageError, so a typo never goes unnoticed.
|
|
10
10
|
Config = Data.define(:protected_patterns, :stale_days, :worktree_dir) do
|
|
11
|
-
# `root` is the main checkout; `home` is where the user file lives
|
|
11
|
+
# `root` is the main checkout; `home` is where the user file lives. The repository file wins
|
|
12
|
+
# key by key, except `protected`: both files add patterns, so a repository can protect more
|
|
13
|
+
# branches but never remove what the user protects.
|
|
12
14
|
def self.load(root:, home: Dir.home)
|
|
13
|
-
|
|
15
|
+
layers = [File.join(home, ConfigKeys::USER_FILE), File.join(root, ConfigKeys::REPO_FILE)]
|
|
14
16
|
.map { |path| read(path) }
|
|
15
|
-
|
|
16
|
-
new(protected_patterns:
|
|
17
|
-
worktree_dir: values['worktree_dir'])
|
|
17
|
+
values = layers.reduce(ConfigKeys::DEFAULTS) { |merged, layer| merged.merge(layer) }
|
|
18
|
+
new(protected_patterns: layers.flat_map { |layer| layer.fetch('protected', []) }.uniq,
|
|
19
|
+
stale_days: values['stale_days'], worktree_dir: values['worktree_dir'])
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def self.defaults
|
|
@@ -85,10 +87,11 @@ module Gitflash
|
|
|
85
87
|
protected_patterns.any? { |pattern| File.fnmatch?(pattern, name) }
|
|
86
88
|
end
|
|
87
89
|
|
|
88
|
-
# Directory for a new worktree.
|
|
89
|
-
# is resolved against the main checkout. Slashes in a branch name stay as
|
|
90
|
+
# Directory for a new worktree. `%<repo>s` and `%<branch>s` are replaced as plain text; a
|
|
91
|
+
# relative path is resolved against the main checkout. Slashes in a branch name stay as
|
|
92
|
+
# folders.
|
|
90
93
|
def worktree_path(root:, branch:)
|
|
91
|
-
path =
|
|
94
|
+
path = worktree_dir.gsub('%<repo>s') { File.basename(root) }.gsub('%<branch>s') { branch }
|
|
92
95
|
File.expand_path(path, root)
|
|
93
96
|
end
|
|
94
97
|
end
|
data/lib/gitflash/config_keys.rb
CHANGED
|
@@ -9,11 +9,23 @@ module Gitflash
|
|
|
9
9
|
DEFAULTS = {
|
|
10
10
|
'protected' => [], 'stale_days' => 30, 'worktree_dir' => DEFAULT_WORKTREE_DIR
|
|
11
11
|
}.freeze
|
|
12
|
+
PLACEHOLDERS = ['%<repo>s', '%<branch>s'].freeze
|
|
13
|
+
MAX_TEMPLATE_LENGTH = 512
|
|
12
14
|
CHECKS = {
|
|
13
15
|
'protected' => [->(v) { v.is_a?(Array) && v.all?(String) },
|
|
14
16
|
'a list of branch name patterns'],
|
|
15
17
|
'stale_days' => [->(v) { v.is_a?(Integer) && v.positive? }, 'a positive whole number'],
|
|
16
|
-
'worktree_dir' => [->(v) {
|
|
18
|
+
'worktree_dir' => [->(v) { template?(v) },
|
|
19
|
+
'a path of up to 512 characters that uses only %<repo>s and %<branch>s']
|
|
17
20
|
}.freeze
|
|
21
|
+
|
|
22
|
+
# A path template: the two placeholders are the only `%` directives, so a width such as
|
|
23
|
+
# `%<repo>999999999s` can never make Ruby build a huge string
|
|
24
|
+
def self.template?(value)
|
|
25
|
+
return false unless value.is_a?(String)
|
|
26
|
+
return false if value.empty? || value.length > MAX_TEMPLATE_LENGTH
|
|
27
|
+
|
|
28
|
+
!PLACEHOLDERS.reduce(value) { |rest, placeholder| rest.gsub(placeholder, '') }.include?('%')
|
|
29
|
+
end
|
|
18
30
|
end
|
|
19
31
|
end
|
|
@@ -16,9 +16,12 @@ module Gitflash
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# Runs a command without a shell and returns [stdout, stderr, success].
|
|
19
|
-
# `env` adds environment variables for this command only.
|
|
19
|
+
# `env` adds environment variables for this command only. The executable is passed as a
|
|
20
|
+
# pair, which keeps Ruby from handing a lone string to a shell.
|
|
20
21
|
def capture(*args, env: {})
|
|
21
|
-
|
|
22
|
+
raise CommandError, 'no command given' if args.empty?
|
|
23
|
+
|
|
24
|
+
stdout, stderr, status = Open3.capture3(env, [args.first, args.first], *args.drop(1))
|
|
22
25
|
[stdout, stderr, status.success?]
|
|
23
26
|
rescue SystemCallError => e
|
|
24
27
|
raise CommandError, "#{args.join(' ')} failed: #{e.message}"
|
|
@@ -1,30 +1,133 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
|
|
3
8
|
module Gitflash
|
|
4
9
|
module Hook
|
|
5
10
|
# Marks the branches an agent just created as agent work (see Ownership), so
|
|
6
11
|
# `gitflash clean --agent` can find them. Runs in the PostToolUse hook, after the command:
|
|
7
12
|
# the branch exists then, and Claude Code only runs it when the command succeeded.
|
|
8
13
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
14
|
+
# Text in a command proves nothing (`false && git branch x` creates nothing), so a branch is
|
|
15
|
+
# only marked when it is new:
|
|
16
|
+
# - the PreToolUse hook records which of the branches the command names exist before it runs
|
|
17
|
+
# (`record`), and a branch that existed then is never marked;
|
|
18
|
+
# - its reflog must say it was created a moment ago ("branch: Created from ..."), which also
|
|
19
|
+
# covers a Claude Code that ran no PreToolUse hook;
|
|
20
|
+
# - it must be unmarked.
|
|
11
21
|
class BranchMarker
|
|
12
22
|
CREATED = 'branch: Created from'
|
|
13
23
|
RECENT_SECONDS = 120
|
|
24
|
+
STALE_RECORD_SECONDS = 24 * 60 * 60
|
|
14
25
|
|
|
15
|
-
def initialize(clock: -> { Time.now })
|
|
26
|
+
def initialize(clock: -> { Time.now }, state_dir: nil)
|
|
16
27
|
@clock = clock
|
|
28
|
+
@state_dir = state_dir || File.join(Dir.tmpdir, "gitflash-hook-#{Process.uid}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# PreToolUse: remember the branches the command may create that exist already
|
|
32
|
+
def record(command, cwd, tool_use_id)
|
|
33
|
+
creations = CommandParser.new(cwd).creations(command)
|
|
34
|
+
return if creations.empty? || (path = record_path(tool_use_id)).nil?
|
|
35
|
+
return unless private_state_dir?
|
|
36
|
+
|
|
37
|
+
prune_records
|
|
38
|
+
File.write(path, JSON.generate(existing_stamps(creations)))
|
|
17
39
|
end
|
|
18
40
|
|
|
19
|
-
#
|
|
20
|
-
def call(command, cwd)
|
|
41
|
+
# PostToolUse: branch names marked as agent work, in the directories the command ran in
|
|
42
|
+
def call(command, cwd, tool_use_id = nil)
|
|
43
|
+
existed = take_record(tool_use_id)
|
|
21
44
|
created = CommandParser.new(cwd).creations(command)
|
|
22
|
-
created.
|
|
45
|
+
created.reject { |creation| already_there?(existed, creation) }
|
|
46
|
+
.select { |creation| mark(creation[:dir], creation[:branch]) }
|
|
23
47
|
.map { |creation| creation[:branch] }.uniq
|
|
24
48
|
end
|
|
25
49
|
|
|
26
50
|
private
|
|
27
51
|
|
|
52
|
+
# { key => stamp } of the named branches that exist now
|
|
53
|
+
def existing_stamps(creations)
|
|
54
|
+
existing = creations.select { |creation| branch_exists?(creation[:dir], creation[:branch]) }
|
|
55
|
+
existing.to_h { |creation| [key(creation), stamp(creation)] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def key(creation)
|
|
59
|
+
"#{creation[:dir]}\0#{creation[:branch]}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A branch that was there before the command and is the same branch now. One that the
|
|
63
|
+
# command deleted and created again has a new reflog, so a new stamp, and counts as new.
|
|
64
|
+
# An empty stamp (no reflog) is no evidence of a new branch either: all that is known is
|
|
65
|
+
# that the branch existed, and claiming a branch a person may have made is the worse
|
|
66
|
+
# mistake, so it is left alone. Without a reflog nothing could be marked in any case.
|
|
67
|
+
def already_there?(existed, creation)
|
|
68
|
+
existed.key?(key(creation)) && existed[key(creation)] == stamp(creation)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Time, commit and message of the oldest reflog entry, which is the creation of the branch;
|
|
72
|
+
# empty when the reflog is off
|
|
73
|
+
def stamp(creation)
|
|
74
|
+
return '' unless Dir.exist?(creation[:dir])
|
|
75
|
+
|
|
76
|
+
stdout, _stderr, success = Git::BashCommand.capture(
|
|
77
|
+
'git', '-C', creation[:dir], 'log', '-g', '--format=%ct %H %gs',
|
|
78
|
+
"#{Repo::HEADS}#{creation[:branch]}", '--'
|
|
79
|
+
)
|
|
80
|
+
success ? stdout.lines(chomp: true).last.to_s : ''
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# One file per tool call, named from a hash so the id can never form a path
|
|
84
|
+
def record_path(tool_use_id)
|
|
85
|
+
return nil if tool_use_id.to_s.empty?
|
|
86
|
+
|
|
87
|
+
File.join(@state_dir, "#{Digest::SHA256.hexdigest(tool_use_id.to_s)}.json")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def take_record(tool_use_id)
|
|
91
|
+
path = record_path(tool_use_id)
|
|
92
|
+
return {} unless path && private_state_dir? && File.file?(path)
|
|
93
|
+
|
|
94
|
+
record = JSON.parse(File.read(path))
|
|
95
|
+
record.is_a?(Hash) ? record : {}
|
|
96
|
+
rescue JSON::ParserError
|
|
97
|
+
{}
|
|
98
|
+
ensure
|
|
99
|
+
FileUtils.rm_f(path) if path
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The directory holds the records of this user only: created with mode 0700, and refused
|
|
103
|
+
# when it is a link, belongs to someone else or is open to the group or others (a shared
|
|
104
|
+
# /tmp lets another user create the name first)
|
|
105
|
+
def private_state_dir?
|
|
106
|
+
FileUtils.mkdir_p(@state_dir, mode: 0o700)
|
|
107
|
+
stat = File.lstat(@state_dir)
|
|
108
|
+
stat.directory? && stat.owned? && stat.mode.nobits?(0o077)
|
|
109
|
+
rescue SystemCallError
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Another hook may delete a record at the same moment: a vanished file is not an error
|
|
114
|
+
def prune_records
|
|
115
|
+
Dir.glob(File.join(@state_dir, '*.json')).each do |file|
|
|
116
|
+
FileUtils.rm_f(file) if @clock.call - File.mtime(file) > STALE_RECORD_SECONDS
|
|
117
|
+
rescue SystemCallError
|
|
118
|
+
next
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def branch_exists?(dir, branch)
|
|
123
|
+
return false unless Dir.exist?(dir)
|
|
124
|
+
|
|
125
|
+
_stdout, _stderr, success = Git::BashCommand.capture(
|
|
126
|
+
'git', '-C', dir, 'show-ref', '--verify', '--quiet', "#{Repo::HEADS}#{branch}"
|
|
127
|
+
)
|
|
128
|
+
success
|
|
129
|
+
end
|
|
130
|
+
|
|
28
131
|
def mark(dir, branch)
|
|
29
132
|
return false unless Dir.exist?(dir)
|
|
30
133
|
|
data/lib/gitflash/hook/claude.rb
CHANGED
|
@@ -29,21 +29,37 @@ module Gitflash
|
|
|
29
29
|
data = JSON.parse(input)
|
|
30
30
|
return after_command(data) if data['hook_event_name'] == POST_EVENT
|
|
31
31
|
|
|
32
|
+
before_command(data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# PreToolUse: remember which branches exist, then snapshot, ask or deny for risky commands
|
|
38
|
+
def before_command(data)
|
|
39
|
+
record_branches(data)
|
|
32
40
|
targets = targets(data)
|
|
33
41
|
return nil if targets.empty?
|
|
34
42
|
return deny(targets) if @mode == 'deny'
|
|
35
43
|
|
|
36
44
|
saved = merge_by_dir(targets).filter_map { |target| save(target) }
|
|
37
|
-
|
|
45
|
+
# Asking is the user's protection: it must not depend on a snapshot having been saved
|
|
46
|
+
saved.empty? && @mode != 'ask' ? nil : output(saved, targets)
|
|
38
47
|
end
|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
# Recording is a side feature: when it fails, snapshots and the ask or deny decision still run
|
|
50
|
+
def record_branches(data)
|
|
51
|
+
return unless bash?(data)
|
|
52
|
+
|
|
53
|
+
@marker.record(command_of(data), data['cwd'] || Dir.pwd, data['tool_use_id'])
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
warn "gitflash hook: could not record branches (#{e.message})"
|
|
56
|
+
end
|
|
41
57
|
|
|
42
58
|
# PostToolUse: mark the branches the command created, and tell Claude
|
|
43
59
|
def after_command(data)
|
|
44
60
|
return nil unless data['tool_name'] == 'Bash'
|
|
45
61
|
|
|
46
|
-
marked = @marker.call(data
|
|
62
|
+
marked = @marker.call(command_of(data), data['cwd'] || Dir.pwd, data['tool_use_id'])
|
|
47
63
|
return nil if marked.empty?
|
|
48
64
|
|
|
49
65
|
context = "gitflash marked #{marked.join(', ')} as agent work, so " \
|
|
@@ -52,11 +68,18 @@ module Gitflash
|
|
|
52
68
|
{ hookSpecificOutput: { hookEventName: POST_EVENT, additionalContext: context } }
|
|
53
69
|
end
|
|
54
70
|
|
|
71
|
+
def bash?(data)
|
|
72
|
+
data['tool_name'] == 'Bash'
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def command_of(data)
|
|
76
|
+
data.dig('tool_input', 'command').to_s
|
|
77
|
+
end
|
|
78
|
+
|
|
55
79
|
def targets(data)
|
|
56
|
-
return [] unless data
|
|
80
|
+
return [] unless bash?(data)
|
|
57
81
|
|
|
58
|
-
|
|
59
|
-
CommandParser.new(data['cwd'] || Dir.pwd).targets(command)
|
|
82
|
+
CommandParser.new(data['cwd'] || Dir.pwd).targets(command_of(data))
|
|
60
83
|
end
|
|
61
84
|
|
|
62
85
|
# One snapshot per directory, covering every command run there
|
|
@@ -93,11 +116,8 @@ module Gitflash
|
|
|
93
116
|
end
|
|
94
117
|
end
|
|
95
118
|
|
|
96
|
-
def output(saved)
|
|
97
|
-
|
|
98
|
-
context = "gitflash saved #{notes.join('; ')}. If this discards work that was still " \
|
|
99
|
-
"needed, restore it with `gitflash undo #{saved.last[1].id}` " \
|
|
100
|
-
'(list snapshots with `gitflash snapshots`).'
|
|
119
|
+
def output(saved, targets)
|
|
120
|
+
context = saved.empty? ? Messages.unsaved(targets) : Messages.saved(saved)
|
|
101
121
|
specific = { hookEventName: EVENT, additionalContext: context }
|
|
102
122
|
if @mode == 'ask'
|
|
103
123
|
specific[:permissionDecision] = 'ask'
|
|
@@ -108,12 +128,8 @@ module Gitflash
|
|
|
108
128
|
end
|
|
109
129
|
|
|
110
130
|
def deny(targets)
|
|
111
|
-
commands = targets.map { |target| "`#{target.command}`" }.join(', ')
|
|
112
|
-
reason = "gitflash blocked #{commands}: it can discard work git cannot restore. " \
|
|
113
|
-
'Use the gitflash equivalent (gitflash reset, gitflash delete, ...), which ' \
|
|
114
|
-
'saves a snapshot first, or run `gitflash snapshot` before retrying.'
|
|
115
131
|
{ hookSpecificOutput: { hookEventName: EVENT, permissionDecision: 'deny',
|
|
116
|
-
permissionDecisionReason:
|
|
132
|
+
permissionDecisionReason: Messages.denial(targets) } }
|
|
117
133
|
end
|
|
118
134
|
|
|
119
135
|
# Stripped stdout of a git command, or nil when it fails
|
|
@@ -17,6 +17,8 @@ module Gitflash
|
|
|
17
17
|
Target = Data.define(:dir, :command, :scope, :branches)
|
|
18
18
|
|
|
19
19
|
PREFIXES = %w[sudo command exec time nohup env].freeze
|
|
20
|
+
SHELLS = %w[bash sh zsh dash ksh].freeze
|
|
21
|
+
MAX_NESTING = 3
|
|
20
22
|
OPTIONS_WITH_VALUE = %w[-c --git-dir --work-tree --namespace --exec-path --config-env].freeze
|
|
21
23
|
|
|
22
24
|
def initialize(cwd)
|
|
@@ -48,20 +50,36 @@ module Gitflash
|
|
|
48
50
|
# subcommand and its arguments
|
|
49
51
|
GitCommand = Data.define(:dir, :command, :subcommand, :args)
|
|
50
52
|
|
|
51
|
-
def git_commands(command_line)
|
|
52
|
-
|
|
53
|
-
segments(command_line).filter_map do |segment|
|
|
53
|
+
def git_commands(command_line, dir = @cwd, depth = 0)
|
|
54
|
+
segments(command_line).flat_map do |segment|
|
|
54
55
|
words = words(segment)
|
|
55
|
-
next if words.empty?
|
|
56
|
+
next [] if words.empty?
|
|
56
57
|
|
|
57
58
|
if words.first == 'cd'
|
|
58
59
|
dir = File.expand_path(words[1] || Dir.home, dir)
|
|
59
|
-
next
|
|
60
|
+
next []
|
|
60
61
|
end
|
|
61
|
-
|
|
62
|
+
commands_in(words, dir, segment.strip, depth)
|
|
62
63
|
end
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
# The git command of one segment, or the ones inside `bash -c "..."` and `eval "..."`
|
|
67
|
+
def commands_in(words, dir, segment, depth)
|
|
68
|
+
return [git_command(words, dir, segment)] if File.basename(words.first) == 'git'
|
|
69
|
+
|
|
70
|
+
script = nested_script(words)
|
|
71
|
+
script && depth < MAX_NESTING ? git_commands(script, dir, depth + 1) : []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The script text passed to a shell (`bash -c`, `sh -lc`) or to `eval`
|
|
75
|
+
def nested_script(words)
|
|
76
|
+
return words.drop(1).join(' ') if words.first == 'eval'
|
|
77
|
+
return nil unless SHELLS.include?(File.basename(words.first))
|
|
78
|
+
|
|
79
|
+
index = words.index { |word| word.match?(/\A-[a-z]*c[a-z]*\z/) }
|
|
80
|
+
index && words[index + 1]
|
|
81
|
+
end
|
|
82
|
+
|
|
65
83
|
# Splits on unquoted ; & | and newlines
|
|
66
84
|
def segments(line)
|
|
67
85
|
parts = [+'']
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# What the PreToolUse hook tells Claude about a risky command
|
|
6
|
+
module Messages
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def saved(saved)
|
|
10
|
+
notes = saved.map { |target, snap| "snapshot #{snap.id} before `#{target.command}`" }
|
|
11
|
+
"gitflash saved #{notes.join('; ')}. If this discards work that was still needed, " \
|
|
12
|
+
"restore it with #{undo_commands(saved)} (list snapshots with `gitflash snapshots`)."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# One command per snapshot: with several directories each snapshot belongs to its own
|
|
16
|
+
def undo_commands(saved)
|
|
17
|
+
return "`gitflash undo #{saved.first[1].id}`" if saved.size == 1
|
|
18
|
+
|
|
19
|
+
saved.map { |target, snap| "`gitflash undo #{snap.id}` (run in #{target.dir})" }.join(', ')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def unsaved(targets)
|
|
23
|
+
"gitflash could not save a snapshot before #{commands(targets)} (the directory is not a " \
|
|
24
|
+
'git work tree or does not exist), so nothing can be restored with `gitflash undo`.'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def denial(targets)
|
|
28
|
+
"gitflash blocked #{commands(targets)}: it can discard work git cannot restore. " \
|
|
29
|
+
'Use the gitflash equivalent (gitflash reset, gitflash delete, ...), which ' \
|
|
30
|
+
'saves a snapshot first, or run `gitflash snapshot` before retrying.'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def commands(targets)
|
|
34
|
+
targets.map { |target| "`#{target.command}`" }.join(', ')
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/gitflash/hook/rules.rb
CHANGED
|
@@ -41,9 +41,9 @@ module Gitflash
|
|
|
41
41
|
def clean(args, _dir)
|
|
42
42
|
flags = args.select { |arg| arg.start_with?('-') }
|
|
43
43
|
short = short_flags(args)
|
|
44
|
-
|
|
45
|
-
dry_run =
|
|
46
|
-
|
|
44
|
+
removes = any?(flags, %w[--force --interactive]) || short.match?(/[fi]/)
|
|
45
|
+
dry_run = flags.include?('--dry-run') || short.include?('n')
|
|
46
|
+
removes && !dry_run ? { scope: %w[worktree] } : nil
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def switch(args, _dir)
|
|
@@ -59,7 +59,9 @@ module Gitflash
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def branch(args, _dir)
|
|
62
|
-
|
|
62
|
+
changes = any?(args, %w[--delete --force --move --copy]) ||
|
|
63
|
+
short_flags(args).match?(/[dDfmMcC]/)
|
|
64
|
+
return nil unless changes
|
|
63
65
|
|
|
64
66
|
{ scope: %w[branches], branches: args.reject { |arg| arg.start_with?('-') } }
|
|
65
67
|
end
|
data/lib/gitflash/hook_cli.rb
CHANGED
|
@@ -14,8 +14,12 @@ module Gitflash
|
|
|
14
14
|
true
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
TERMINAL_NOTE = 'gitflash hook claude is run by Claude Code, not by hand. Use ' \
|
|
18
|
+
'`gitflash hook install` to set it up and `gitflash hook status` to check it.'
|
|
19
|
+
|
|
17
20
|
desc 'claude',
|
|
18
|
-
'Run as a Claude Code hook (PreToolUse and PostToolUse; reads the input on stdin)'
|
|
21
|
+
'Run as a Claude Code hook (PreToolUse and PostToolUse; reads the input on stdin)',
|
|
22
|
+
hide: true
|
|
19
23
|
long_desc <<~TEXT
|
|
20
24
|
Reads the Claude Code hook input on stdin. When the Bash command can discard work git
|
|
21
25
|
cannot restore (for example reset --hard, clean -f, checkout -- ., branch -D, stash drop),
|
|
@@ -29,10 +33,13 @@ module Gitflash
|
|
|
29
33
|
branch NAME, worktree add) as agent work, so `gitflash clean --agent` can find them. A branch
|
|
30
34
|
is only marked when it is new (its reflog says it was created a moment ago) and unmarked.
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
Not meant to be run by hand: in a terminal it only prints a note. Errors never block the
|
|
37
|
+
command: they are printed to stderr and the hook exits with 0.
|
|
33
38
|
TEXT
|
|
34
39
|
option :mode, type: :string, default: 'snapshot', enum: Hook::Claude::MODES
|
|
35
40
|
def claude
|
|
41
|
+
return puts(TERMINAL_NOTE) if $stdin.tty?
|
|
42
|
+
|
|
36
43
|
output = Hook::Claude.new(mode: options[:mode]).call($stdin.read)
|
|
37
44
|
puts JSON.generate(output) if output
|
|
38
45
|
rescue StandardError => e
|
data/lib/gitflash/repo.rb
CHANGED
|
@@ -39,12 +39,13 @@ module Gitflash
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def toplevel
|
|
42
|
-
@bash.exec('git', 'rev-parse', '--show-toplevel').
|
|
42
|
+
@bash.exec('git', 'rev-parse', '--show-toplevel').delete_suffix("\n")
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
# Root of the main checkout; differs from toplevel inside a linked worktree
|
|
46
46
|
def main_root
|
|
47
|
-
common = @bash.exec('git', 'rev-parse', '--path-format=absolute',
|
|
47
|
+
common = @bash.exec('git', 'rev-parse', '--path-format=absolute',
|
|
48
|
+
'--git-common-dir').delete_suffix("\n")
|
|
48
49
|
File.basename(common) == '.git' ? File.dirname(common) : toplevel
|
|
49
50
|
end
|
|
50
51
|
|
|
@@ -48,13 +48,15 @@ module Gitflash
|
|
|
48
48
|
private
|
|
49
49
|
|
|
50
50
|
def git(*, env: {})
|
|
51
|
-
|
|
51
|
+
# Only git's final newline goes: a path may start or end with whitespace, even a carriage
|
|
52
|
+
# return, which `chomp` would take together with the newline
|
|
53
|
+
@bash.exec('git', *, env: env).delete_suffix("\n")
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
# Stripped stdout, or nil when the command fails
|
|
55
57
|
def try(*)
|
|
56
58
|
stdout, _stderr, success = @bash.capture('git', *)
|
|
57
|
-
success ? stdout.
|
|
59
|
+
success ? stdout.delete_suffix("\n") : nil
|
|
58
60
|
end
|
|
59
61
|
|
|
60
62
|
def rev(ref)
|
|
@@ -122,7 +124,10 @@ module Gitflash
|
|
|
122
124
|
sizes = file_sizes(top, paths)
|
|
123
125
|
small, large = sizes.partition { |_path, size| size <= MAX_UNTRACKED_FILE_BYTES }
|
|
124
126
|
.map { |group| group.map(&:first) }
|
|
125
|
-
|
|
127
|
+
# File names are literal paths, never pathspec magic such as `:(exclude)*`
|
|
128
|
+
small.each_slice(ADD_BATCH_SIZE) do |batch|
|
|
129
|
+
git('--literal-pathspecs', '-C', top, 'add', '--', *batch, env: env)
|
|
130
|
+
end
|
|
126
131
|
large
|
|
127
132
|
end
|
|
128
133
|
end
|
|
@@ -15,5 +15,14 @@ module Gitflash
|
|
|
15
15
|
format(char.ord > 0xFF ? '\\u%04X' : '\\x%02X', char.ord)
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
# Text that must stay on one line, such as a path inside a bullet list: as `safe`, and a
|
|
20
|
+
# newline or tab in it is shown as an escape too, so it cannot start a fake row. A literal
|
|
21
|
+
# backslash is doubled first, so two different texts never look the same (a real newline
|
|
22
|
+
# shows as `\n`, the two characters backslash and n as `\\n`); that keeps menu labels unique.
|
|
23
|
+
def line(text)
|
|
24
|
+
doubled = text.to_s.scrub.gsub('\\') { '\\\\' }
|
|
25
|
+
safe(doubled).gsub("\n") { '\\n' }.gsub("\t") { '\\t' }
|
|
26
|
+
end
|
|
18
27
|
end
|
|
19
28
|
end
|
data/lib/gitflash/ui.rb
CHANGED
|
@@ -69,7 +69,7 @@ module Gitflash
|
|
|
69
69
|
message = "#{summary}\nRe-run with --yes to confirm."
|
|
70
70
|
raise ConfirmationRequired.new(message, plan: plan) unless interactive?
|
|
71
71
|
|
|
72
|
-
prompt.proceed_with_warning(summary) { true } == true
|
|
72
|
+
prompt.proceed_with_warning(TerminalText.safe(summary)) { true } == true
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
private
|
data/lib/gitflash/version.rb
CHANGED
data/lib/gitflash/worktree.rb
CHANGED
|
@@ -17,16 +17,16 @@ module Gitflash
|
|
|
17
17
|
# Why this worktree must not be removed, nil when it can go. `current` is true for the
|
|
18
18
|
# worktree gitflash runs in. The main checkout and the current one are never removable.
|
|
19
19
|
def removal_blocker(force:, current:)
|
|
20
|
-
return "#{path} is the main checkout" if main?
|
|
21
|
-
return "#{path} is the current worktree" if current
|
|
20
|
+
return "#{TerminalText.line(path)} is the main checkout" if main?
|
|
21
|
+
return "#{TerminalText.line(path)} is the current worktree" if current
|
|
22
22
|
|
|
23
23
|
in_use_blocker unless force || (missing? && !locked?)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def in_use_blocker
|
|
27
|
-
return "#{path} is locked (use --force)" if locked?
|
|
27
|
+
return "#{TerminalText.line(path)} is locked (use --force)" if locked?
|
|
28
28
|
|
|
29
|
-
"#{path} has uncommitted changes (use --force)" if dirty? != false
|
|
29
|
+
"#{TerminalText.line(path)} has uncommitted changes (use --force)" if dirty? != false
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gitflash
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.0.
|
|
4
|
+
version: 0.7.0.beta2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kleanthis Mitsioulis
|
|
@@ -75,6 +75,7 @@ files:
|
|
|
75
75
|
- lib/gitflash.rb
|
|
76
76
|
- lib/gitflash/branch.rb
|
|
77
77
|
- lib/gitflash/cherry_pick.rb
|
|
78
|
+
- lib/gitflash/cherry_pick/log_parser.rb
|
|
78
79
|
- lib/gitflash/cleanup.rb
|
|
79
80
|
- lib/gitflash/cli.rb
|
|
80
81
|
- lib/gitflash/command_runner.rb
|
|
@@ -118,6 +119,7 @@ files:
|
|
|
118
119
|
- lib/gitflash/hook/claude.rb
|
|
119
120
|
- lib/gitflash/hook/command_parser.rb
|
|
120
121
|
- lib/gitflash/hook/creations.rb
|
|
122
|
+
- lib/gitflash/hook/messages.rb
|
|
121
123
|
- lib/gitflash/hook/rules.rb
|
|
122
124
|
- lib/gitflash/hook/settings.rb
|
|
123
125
|
- lib/gitflash/hook_cli.rb
|