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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8466fec203a9fdd7ac08e721fb0bb624fb8f486e68f9b369660e5d369e717ad
4
- data.tar.gz: 53243a2e0dc1d5877bd5541999fec636e89ac882b9af78d84790ef32be3e12cb
3
+ metadata.gz: d5d05aaa23c964d36999d266023460edddaaaf9b6298e02143de593b583d79f9
4
+ data.tar.gz: 5112665666f18a4a91a025765a33f3b5591a4d02adad72c1512624c66d245586
5
5
  SHA512:
6
- metadata.gz: 27b78d4baac19b15a9f6bef731cc0ec3622ee5c51a2ee82ab9f20a63a5f50d6ecedb3c4f1651d0849eae970f5a66c20480272d0bf00b0284e8434f1893805901
7
- data.tar.gz: 1c1f6e76de4e2f67fb7c1c591e9569d8578a96cad8cc9e58ff75fd9448abaa8ead4e7b96cef519d6a4a991e75299e07eaafcc8d060896b8742b9c7f4ec4b93f5
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.beta1` are only installed when you ask for them: `gem install gitflash --pre`, or `gem 'gitflash', '0.7.0.beta1'` in a Gemfile.
27
+ Prerelease versions such as `0.7.0.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
@@ -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
@@ -19,9 +19,6 @@ module Gitflash
19
19
  # The state of an unfinished pick: files with conflicts and the commits still to apply
20
20
  Progress = Data.define(:conflicted, :remaining, :current)
21
21
 
22
- RECORD = "\x1e"
23
- SEPARATOR = "\x1f"
24
- FORMAT = ['%m', '%H', '%s', '%an', '%cI'].join(SEPARATOR)
25
22
  ENV_NO_EDITOR = { 'GIT_EDITOR' => 'true', 'GIT_SEQUENCE_EDITOR' => 'true' }.freeze
26
23
 
27
24
  def initialize(bash: Git::BashCommand)
@@ -38,10 +35,11 @@ module Gitflash
38
35
  # Commits of `source_ref` that are not merges, oldest first, each marked as applied when an
39
36
  # equivalent change (same patch) is already on HEAD
40
37
  def candidates(source_ref)
41
- output = @bash.exec('git', 'log', '--left-right', '--cherry-mark', '--right-only',
42
- '--no-merges', '--reverse', '--shortstat', "--format=#{RECORD}#{FORMAT}",
38
+ output = @bash.exec('git', 'log', '-z', '--left-right', '--cherry-mark', '--right-only',
39
+ '--no-merges', '--reverse', '--shortstat', "--format=#{LogParser::FORMAT}",
43
40
  "HEAD...#{source_ref}", '--')
44
- output.split(RECORD).reject { |record| record.strip.empty? }.map { |record| parse(record) }
41
+ eligible = eligible_shas(source_ref)
42
+ LogParser.parse(output).select { |candidate| eligible.include?(candidate.sha) }.uniq(&:sha)
45
43
  end
46
44
 
47
45
  # Applies the commits (full shas, in the given order) with `git cherry-pick`.
@@ -70,18 +68,12 @@ module Gitflash
70
68
 
71
69
  private
72
70
 
73
- def parse(record)
74
- line, *rest = record.split("\n", 2)
75
- mark, sha, subject, author, date = line.split(SEPARATOR, 5)
76
- files, insertions, deletions = stat(rest.first.to_s)
77
- Candidate.new(sha: sha, subject: subject.to_s, author: author, date: date,
78
- applied: mark == '=', files: files, insertions: insertions,
79
- deletions: deletions)
80
- end
81
-
82
- def stat(text)
83
- [text[/(\d+) files? changed/, 1], text[/(\d+) insertions?\(\+\)/, 1],
84
- text[/(\d+) deletions?\(-\)/, 1]].map(&:to_i)
71
+ # The commits of the source that are not merges and not on HEAD, listed by git without any
72
+ # text a commit author controls. A commit message holding the separators of the log format
73
+ # could forge extra records; only SHAs found here become candidates.
74
+ def eligible_shas(source_ref)
75
+ @bash.exec('git', 'rev-list', '--right-only', '--no-merges', "HEAD...#{source_ref}", '--')
76
+ .lines(chomp: true).to_set
85
77
  end
86
78
 
87
79
  def conflicted_files
data/lib/gitflash/cli.rb CHANGED
@@ -99,7 +99,7 @@ module Gitflash
99
99
  run_command('gc', Commands::Gc)
100
100
  end
101
101
 
102
- desc 'hook SUBCOMMAND', 'Agent hooks: claude (run the hook), install (register it)'
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
- { scope: scope, file: settings.path(scope), command: hook_command,
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]} (#{row[:ignored].join(', ')})"
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 { |row| "Removed #{row[:path]} (undo: #{row[:undo]})" }
60
- lines += result[:failed].map { |row| "Not removed #{row[:path]}: #{row[:error]}" }
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
- "\nThe worktree #{plan[:recreate_worktree]} was added again and may be partly restored; " \
84
- "remove it with: gitflash wt remove #{plan[:recreate_worktree]} --force"
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
- lines << "* add worktree #{plan[:recreate_worktree]} again" if plan[:recreate_worktree]
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', "Branch #{branch} is already checked out at #{holder.path}")
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', "#{path} already exists and is not an empty directory")
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
- [worktree.path.ljust(width), worktree.branch || "(detached #{worktree.head.to_s[0, 7]})",
22
- flags(worktree)].join(' ').rstrip
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
- return planned(plan(worktree), "Would #{verb} #{worktree.path}") if ui.dry_run?
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, "#{worktree.path} is #{lock? ? 'already locked' : 'not locked'}")
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
- return planned(plan, "Would move #{plan[:from]} to #{plan[:to]}") if ui.dry_run?
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', "#{worktree.path} is the current 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', "#{worktree.path} is locked (use --force)")
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
@@ -34,7 +34,7 @@ module Gitflash
34
34
  end
35
35
 
36
36
  def bullets(plan)
37
- plan[:worktrees].map { |path| "* #{path}" }.join("\n")
37
+ plan[:worktrees].map { |path| "* #{TerminalText.line(path)}" }.join("\n")
38
38
  end
39
39
  end
40
40
  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| ["#{wt.branch || '(detached)'} #{wt.path}", wt.path] }
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| "* #{worktree.path} (#{worktree.branch || 'detached'})" }.join("\n")
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)
@@ -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
- values = [File.join(home, ConfigKeys::USER_FILE), File.join(root, ConfigKeys::REPO_FILE)]
15
+ layers = [File.join(home, ConfigKeys::USER_FILE), File.join(root, ConfigKeys::REPO_FILE)]
14
16
  .map { |path| read(path) }
15
- .reduce(ConfigKeys::DEFAULTS) { |merged, layer| merged.merge(layer) }
16
- new(protected_patterns: values['protected'], stale_days: values['stale_days'],
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. `%{repo}` and `%{branch}` are replaced; a relative path
89
- # is resolved against the main checkout. Slashes in a branch name stay as folders.
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 = format(worktree_dir, repo: File.basename(root), branch: branch)
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
@@ -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) { v.is_a?(String) && !v.empty? }, 'a non-empty string']
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
- stdout, stderr, status = Open3.capture3(env, *args)
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
- # A branch is marked only when it is unmarked and its reflog says it was created a moment
10
- # ago ("branch: Created from ..."), so a branch that was already there is never claimed.
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
- # Branch names marked as agent work, in the directories the command ran in
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.select { |creation| mark(creation[:dir], creation[:branch]) }
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
 
@@ -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
- saved.empty? ? nil : output(saved)
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
- private
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.dig('tool_input', 'command').to_s, data['cwd'] || Dir.pwd)
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['tool_name'] == 'Bash'
80
+ return [] unless bash?(data)
57
81
 
58
- command = data.dig('tool_input', 'command').to_s
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
- notes = saved.map { |target, snap| "snapshot #{snap.id} before `#{target.command}`" }
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: reason } }
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
- dir = @cwd
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
- git_command(words, dir, segment.strip) if File.basename(words.first) == 'git'
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
@@ -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
- force = flags.include?('--force') || short.include?('f')
45
- dry_run = any?(flags, %w[--dry-run --interactive]) || short.match?(/[ni]/)
46
- force && !dry_run ? { scope: %w[worktree] } : nil
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
- return nil unless any?(args, %w[-d -D --delete -f --force -m -M --move -C --copy])
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
@@ -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
- Errors never block the command: they are printed to stderr and the hook exits with 0.
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').strip
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', '--git-common-dir').strip
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
- @bash.exec('git', *, env: env).strip
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.strip : nil
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
- small.each_slice(ADD_BATCH_SIZE) { |batch| git('-C', top, 'add', '--', *batch, env: env) }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gitflash
4
- VERSION = '0.7.0.beta1'
4
+ VERSION = '0.7.0.beta2'
5
5
  end
@@ -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.beta1
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