gitflash 0.6.0 → 0.7.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +26 -2
  4. data/command_descriptions.yml +76 -0
  5. data/lib/gitflash/branch.rb +7 -2
  6. data/lib/gitflash/cherry_pick.rb +128 -0
  7. data/lib/gitflash/cleanup.rb +70 -0
  8. data/lib/gitflash/cli.rb +37 -0
  9. data/lib/gitflash/commands/base.rb +9 -3
  10. data/lib/gitflash/commands/branches.rb +1 -0
  11. data/lib/gitflash/commands/clean.rb +51 -0
  12. data/lib/gitflash/commands/clean_criteria.rb +29 -0
  13. data/lib/gitflash/commands/delete.rb +17 -5
  14. data/lib/gitflash/commands/hook_install.rb +17 -9
  15. data/lib/gitflash/commands/hook_status.rb +24 -6
  16. data/lib/gitflash/commands/mark.rb +85 -0
  17. data/lib/gitflash/commands/pick.rb +131 -0
  18. data/lib/gitflash/commands/pick_control.rb +67 -0
  19. data/lib/gitflash/commands/pick_outcome.rb +52 -0
  20. data/lib/gitflash/commands/removal_report.rb +65 -0
  21. data/lib/gitflash/commands/undo.rb +38 -6
  22. data/lib/gitflash/commands/worktree_add.rb +98 -0
  23. data/lib/gitflash/commands/worktree_clean.rb +49 -0
  24. data/lib/gitflash/commands/worktree_command.rb +28 -0
  25. data/lib/gitflash/commands/worktree_list.rb +48 -0
  26. data/lib/gitflash/commands/worktree_lock.rb +54 -0
  27. data/lib/gitflash/commands/worktree_move.rb +47 -0
  28. data/lib/gitflash/commands/worktree_prune.rb +41 -0
  29. data/lib/gitflash/commands/worktree_remove.rb +119 -0
  30. data/lib/gitflash/commands/worktree_unlock.rb +12 -0
  31. data/lib/gitflash/config.rb +95 -0
  32. data/lib/gitflash/config_keys.rb +19 -0
  33. data/lib/gitflash/git/in_directory.rb +29 -0
  34. data/lib/gitflash/hook/branch_marker.rb +52 -0
  35. data/lib/gitflash/hook/claude.rb +23 -3
  36. data/lib/gitflash/hook/command_parser.rb +29 -9
  37. data/lib/gitflash/hook/creations.rb +85 -0
  38. data/lib/gitflash/hook/settings.rb +18 -5
  39. data/lib/gitflash/hook_cli.rb +12 -4
  40. data/lib/gitflash/ownership.rb +59 -0
  41. data/lib/gitflash/protection.rb +19 -0
  42. data/lib/gitflash/repo.rb +2 -3
  43. data/lib/gitflash/restore.rb +8 -7
  44. data/lib/gitflash/state_capture.rb +2 -2
  45. data/lib/gitflash/version.rb +1 -1
  46. data/lib/gitflash/worktree.rb +32 -0
  47. data/lib/gitflash/worktree_cli.rb +115 -0
  48. data/lib/gitflash/worktree_revival.rb +55 -0
  49. data/lib/gitflash/worktrees.rb +147 -0
  50. data/schema/v1.json +1050 -2
  51. metadata +30 -1
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Hook
5
+ # Marks the branches an agent just created as agent work (see Ownership), so
6
+ # `gitflash clean --agent` can find them. Runs in the PostToolUse hook, after the command:
7
+ # the branch exists then, and Claude Code only runs it when the command succeeded.
8
+ #
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.
11
+ class BranchMarker
12
+ CREATED = 'branch: Created from'
13
+ RECENT_SECONDS = 120
14
+
15
+ def initialize(clock: -> { Time.now })
16
+ @clock = clock
17
+ end
18
+
19
+ # Branch names marked as agent work, in the directories the command ran in
20
+ def call(command, cwd)
21
+ created = CommandParser.new(cwd).creations(command)
22
+ created.select { |creation| mark(creation[:dir], creation[:branch]) }
23
+ .map { |creation| creation[:branch] }.uniq
24
+ end
25
+
26
+ private
27
+
28
+ def mark(dir, branch)
29
+ return false unless Dir.exist?(dir)
30
+
31
+ Dir.chdir(dir) do
32
+ ownership = Ownership.new
33
+ next false unless created_recently?(branch) && !ownership.all.key?(branch)
34
+
35
+ ownership.mark(branch, 'agent')
36
+ true
37
+ end
38
+ end
39
+
40
+ # The oldest reflog entry of the branch is its creation, and it is recent
41
+ def created_recently?(branch)
42
+ stdout, _stderr, success = Git::BashCommand.capture(
43
+ 'git', 'log', '-g', '--format=%ct%x1f%gs', "#{Repo::HEADS}#{branch}", '--'
44
+ )
45
+ return false unless success
46
+
47
+ created, subject = stdout.lines(chomp: true).last.to_s.split("\x1f", 2)
48
+ subject.to_s.start_with?(CREATED) && @clock.call.to_i - created.to_i <= RECENT_SECONDS
49
+ end
50
+ end
51
+ end
52
+ end
@@ -4,7 +4,8 @@ require 'json'
4
4
 
5
5
  module Gitflash
6
6
  module Hook
7
- # Claude Code PreToolUse hook for the Bash tool.
7
+ # Claude Code hook for the Bash tool. PostToolUse input marks the branches the command
8
+ # created as agent work (BranchMarker); everything below describes PreToolUse.
8
9
  #
9
10
  # Reads the hook input (JSON on stdin) and, when the command can lose work git cannot
10
11
  # restore, acts according to the mode:
@@ -15,14 +16,20 @@ module Gitflash
15
16
  class Claude
16
17
  MODES = %w[snapshot ask deny].freeze
17
18
  EVENT = 'PreToolUse'
19
+ POST_EVENT = 'PostToolUse'
18
20
 
19
- def initialize(mode: 'snapshot', snapshots: -> { Gitflash::Snapshots.new })
21
+ def initialize(mode: 'snapshot', snapshots: -> { Gitflash::Snapshots.new },
22
+ marker: BranchMarker.new)
20
23
  @mode = mode
21
24
  @snapshots = snapshots
25
+ @marker = marker
22
26
  end
23
27
 
24
28
  def call(input)
25
- targets = targets(JSON.parse(input))
29
+ data = JSON.parse(input)
30
+ return after_command(data) if data['hook_event_name'] == POST_EVENT
31
+
32
+ targets = targets(data)
26
33
  return nil if targets.empty?
27
34
  return deny(targets) if @mode == 'deny'
28
35
 
@@ -32,6 +39,19 @@ module Gitflash
32
39
 
33
40
  private
34
41
 
42
+ # PostToolUse: mark the branches the command created, and tell Claude
43
+ def after_command(data)
44
+ return nil unless data['tool_name'] == 'Bash'
45
+
46
+ marked = @marker.call(data.dig('tool_input', 'command').to_s, data['cwd'] || Dir.pwd)
47
+ return nil if marked.empty?
48
+
49
+ context = "gitflash marked #{marked.join(', ')} as agent work, so " \
50
+ '`gitflash clean --agent` can find it. Change it with ' \
51
+ '`gitflash mark BRANCH --owner human`.'
52
+ { hookSpecificOutput: { hookEventName: POST_EVENT, additionalContext: context } }
53
+ end
54
+
35
55
  def targets(data)
36
56
  return [] unless data['tool_name'] == 'Bash'
37
57
 
@@ -23,7 +23,32 @@ module Gitflash
23
23
  @cwd = cwd
24
24
  end
25
25
 
26
+ # Git commands that can lose work, with what to save before them
26
27
  def targets(command_line)
28
+ git_commands(command_line).filter_map do |git|
29
+ rule = Rules.for(git.subcommand, git.args, git.dir)
30
+ next unless rule
31
+
32
+ Target.new(dir: rule.fetch(:dir, git.dir), command: git.command, scope: rule[:scope],
33
+ branches: rule[:branches] || [])
34
+ end
35
+ end
36
+
37
+ # Branches that git commands in the line create, as { dir:, branch: }
38
+ def creations(command_line)
39
+ git_commands(command_line).filter_map do |git|
40
+ branch = Creations.for(git.subcommand, git.args)
41
+ { dir: git.dir, branch: branch } if branch
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # A git command found in the line: the directory it runs in (after `cd` and `-C`), the
48
+ # subcommand and its arguments
49
+ GitCommand = Data.define(:dir, :command, :subcommand, :args)
50
+
51
+ def git_commands(command_line)
27
52
  dir = @cwd
28
53
  segments(command_line).filter_map do |segment|
29
54
  words = words(segment)
@@ -33,12 +58,10 @@ module Gitflash
33
58
  dir = File.expand_path(words[1] || Dir.home, dir)
34
59
  next
35
60
  end
36
- git_target(words, dir, segment.strip) if File.basename(words.first) == 'git'
61
+ git_command(words, dir, segment.strip) if File.basename(words.first) == 'git'
37
62
  end
38
63
  end
39
64
 
40
- private
41
-
42
65
  # Splits on unquoted ; & | and newlines
43
66
  def segments(line)
44
67
  parts = [+'']
@@ -78,13 +101,10 @@ module Gitflash
78
101
  word.delete_prefix('(').delete_prefix('{').delete_suffix(')').delete_suffix('}')
79
102
  end
80
103
 
81
- def git_target(words, dir, command)
104
+ def git_command(words, dir, command)
82
105
  index, dir = skip_git_options(words, dir)
83
- rule = Rules.for(words[index], words[(index + 1)..] || [], dir)
84
- return nil unless rule
85
-
86
- Target.new(dir: rule.fetch(:dir, dir), command: command, scope: rule[:scope],
87
- branches: rule[:branches] || [])
106
+ GitCommand.new(dir: dir, command: command, subcommand: words[index],
107
+ args: words[(index + 1)..] || [])
88
108
  end
89
109
 
90
110
  # Index of the subcommand, and the directory after any -C options
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Hook
5
+ # Which git commands create a branch, and the name of it: `checkout -b`, `switch -c`,
6
+ # `branch NAME` and `worktree add -b` (or a path without a commit, which names the branch
7
+ # after the directory). Returns the name, or nil when the command creates no branch.
8
+ module Creations
9
+ NOT_CREATING = %w[
10
+ -d -D --delete -m -M --move -c -C --copy -u --set-upstream-to --unset-upstream
11
+ --edit-description --list -a --all -r --remotes -v -vv --verbose --show-current --contains
12
+ --no-contains --merged --no-merged --points-at
13
+ ].freeze
14
+ WORKTREE_VALUE_OPTIONS = %w[-b -B --reason --orphan].freeze
15
+
16
+ module_function
17
+
18
+ def for(subcommand, args)
19
+ case subcommand
20
+ when 'checkout' then flagged(args, %w[-b -B])
21
+ when 'switch' then flagged(args, %w[-c -C --create --force-create])
22
+ when 'branch' then plain_branch(args)
23
+ when 'worktree' then worktree(args)
24
+ end
25
+ end
26
+
27
+ # The value of the first of `flags`: `-b NAME`, `-bNAME` or `--create=NAME`
28
+ def flagged(args, flags)
29
+ args.each_with_index do |arg, index|
30
+ return args[index + 1] if flags.include?(arg) && args[index + 1]
31
+
32
+ value = attached_value(arg, flags)
33
+ return value if value
34
+ end
35
+ nil
36
+ end
37
+
38
+ def attached_value(arg, flags)
39
+ flags.each do |flag|
40
+ if flag.start_with?('--')
41
+ return arg.delete_prefix("#{flag}=") if arg.start_with?("#{flag}=")
42
+ elsif arg.start_with?(flag) && arg.length > flag.length && !arg.start_with?('--')
43
+ return arg.delete_prefix(flag)
44
+ end
45
+ end
46
+ nil
47
+ end
48
+
49
+ # `git branch NAME [START]` creates NAME; listing, deleting or renaming flags mean it does not
50
+ def plain_branch(args)
51
+ return nil if args.intersect?(NOT_CREATING)
52
+
53
+ args.find { |arg| !arg.start_with?('-') }
54
+ end
55
+
56
+ def worktree(args)
57
+ return nil unless args.first == 'add'
58
+
59
+ rest = args.drop(1)
60
+ named = flagged(rest, %w[-b -B])
61
+ return named if named
62
+ return nil if rest.include?('--detach') || rest.include?('--orphan')
63
+
64
+ path = positional(rest)
65
+ path.size == 1 ? File.basename(path.first) : nil
66
+ end
67
+
68
+ # Arguments that are not options or the values of options that take one
69
+ def positional(args)
70
+ result = []
71
+ skip = false
72
+ args.each do |arg|
73
+ if skip
74
+ skip = false
75
+ elsif WORKTREE_VALUE_OPTIONS.include?(arg)
76
+ skip = true
77
+ elsif !arg.start_with?('-')
78
+ result << arg
79
+ end
80
+ end
81
+ result
82
+ end
83
+ end
84
+ end
85
+ end
@@ -49,11 +49,24 @@ module Gitflash
49
49
  raise Error.new("#{path} is not valid JSON: #{e.message}", code: 'invalid_settings')
50
50
  end
51
51
 
52
- # The gitflash hook entry in parsed settings, or nil
53
- def entry(settings)
54
- groups = Array(settings.dig('hooks', 'PreToolUse'))
55
- groups.flat_map { |group| Array(group['hooks']) }
56
- .find { |hook| hook['command'].to_s.match?(PATTERN) }
52
+ # The gitflash hook entry for an event (PreToolUse by default) in parsed settings, or nil.
53
+ # Only groups whose matcher covers the Bash tool count: a gitflash command under another
54
+ # matcher (Edit, say) never runs for Bash commands.
55
+ def entry(settings, event = 'PreToolUse')
56
+ groups = Array(settings.dig('hooks', event))
57
+ bash = groups.select { |group| bash_matcher?(group['matcher']) }
58
+ bash.flat_map { |group| Array(group['hooks']) }
59
+ .find { |hook| hook['command'].to_s.match?(PATTERN) }
60
+ end
61
+
62
+ # Claude Code matches `matcher` against the tool name: empty or `*` is every tool, anything
63
+ # else is a pattern such as `Bash` or `Bash|Edit`
64
+ def bash_matcher?(matcher)
65
+ return true if matcher.to_s.empty? || matcher == '*'
66
+
67
+ 'Bash'.match?(/\A(?:#{matcher})\z/)
68
+ rescue RegexpError
69
+ false
57
70
  end
58
71
  end
59
72
  end
@@ -14,7 +14,8 @@ module Gitflash
14
14
  true
15
15
  end
16
16
 
17
- desc 'claude', 'Run as a Claude Code PreToolUse hook (reads the hook input on stdin)'
17
+ desc 'claude',
18
+ 'Run as a Claude Code hook (PreToolUse and PostToolUse; reads the input on stdin)'
18
19
  long_desc <<~TEXT
19
20
  Reads the Claude Code hook input on stdin. When the Bash command can discard work git
20
21
  cannot restore (for example reset --hard, clean -f, checkout -- ., branch -D, stash drop),
@@ -24,6 +25,10 @@ module Gitflash
24
25
  --mode ask save a snapshot and ask the user to approve the command
25
26
  --mode deny block the command and point to the gitflash equivalent
26
27
 
28
+ As a PostToolUse hook it marks the branches the command created (git checkout -b, switch -c,
29
+ branch NAME, worktree add) as agent work, so `gitflash clean --agent` can find them. A branch
30
+ is only marked when it is new (its reflog says it was created a moment ago) and unmarked.
31
+
27
32
  Errors never block the command: they are printed to stderr and the hook exits with 0.
28
33
  TEXT
29
34
  option :mode, type: :string, default: 'snapshot', enum: Hook::Claude::MODES
@@ -36,8 +41,10 @@ module Gitflash
36
41
 
37
42
  desc 'install', 'Register the gitflash hook in Claude Code settings'
38
43
  long_desc <<~TEXT
39
- Adds `gitflash hook claude` as a PreToolUse hook for Bash commands. Keeps the rest of the
40
- settings file unchanged and does nothing when the hook is already installed.
44
+ Adds `gitflash hook claude` as a PreToolUse hook (snapshots before destructive commands) and
45
+ as a PostToolUse hook (marks branches an agent creates) for Bash commands. Keeps the rest of
46
+ the settings file unchanged and does nothing when both are already installed. Run it again
47
+ to add the PostToolUse hook to an older install.
41
48
 
42
49
  --scope local .claude/settings.local.json at the repository root (default, not
43
50
  committed); applies to sessions anywhere in the repository and its worktrees
@@ -59,7 +66,8 @@ module Gitflash
59
66
  desc 'status', 'Show whether the gitflash hook protects Claude Code sessions here'
60
67
  long_desc <<~TEXT
61
68
  Lists the Claude Code settings files that apply to the current directory, whether each
62
- holds the gitflash hook and which sessions it applies to, and whether the gitflash
69
+ holds the gitflash hooks (and whether branch marking is on) and which sessions it applies to,
70
+ and whether the gitflash
63
71
  executable the hook runs is on PATH. Works outside a git repository (user scope only).
64
72
  TEXT
65
73
  def status
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Who created a branch: `agent` or `human`. Stored in git config as
5
+ # `branch.<name>.gitflash-owner`, which git removes together with the branch.
6
+ # A branch without a mark counts as human work and is never selected for automatic cleanup.
7
+ class Ownership
8
+ OWNERS = %w[agent human].freeze
9
+ SUFFIX = '.gitflash-owner'
10
+ PREFIX = 'branch.'
11
+
12
+ def initialize(bash: Git::BashCommand)
13
+ @bash = bash
14
+ end
15
+
16
+ # { branch name => owner } for every marked branch
17
+ def all
18
+ stdout, _stderr, success = @bash.capture('git', 'config', '--local', '-z', '--get-regexp',
19
+ '^branch\..+\.gitflash-owner$')
20
+ return {} unless success
21
+
22
+ stdout.split("\0").filter_map { |entry| parse(entry) }.to_h
23
+ end
24
+
25
+ # The branches with their `owner` set from the marks
26
+ def annotate(branches)
27
+ owners = all
28
+ branches.map { |branch| branch.with(owner: owners[branch.name]) }
29
+ end
30
+
31
+ def mark(branch, owner)
32
+ unless OWNERS.include?(owner)
33
+ raise UsageError.new("Unknown owner '#{owner}' (use #{OWNERS.join(' or ')})",
34
+ code: 'invalid_usage')
35
+ end
36
+
37
+ @bash.exec('git', 'config', '--local', key(branch), owner)
38
+ end
39
+
40
+ # Removes the mark; a branch without a mark is already unmarked
41
+ def clear(branch)
42
+ @bash.capture('git', 'config', '--local', '--unset', key(branch))
43
+ end
44
+
45
+ private
46
+
47
+ def key(branch)
48
+ "#{PREFIX}#{branch}#{SUFFIX}"
49
+ end
50
+
51
+ # Entry from `-z`: "branch.<name>.gitflash-owner\nvalue". Branch names may contain dots.
52
+ def parse(entry)
53
+ full_key, value = entry.split("\n", 2)
54
+ return nil unless full_key.start_with?(PREFIX) && full_key.end_with?(SUFFIX)
55
+
56
+ [full_key.delete_prefix(PREFIX).delete_suffix(SUFFIX), value.to_s.strip]
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Branches gitflash never deletes: the current and default branch, `main`, `master`, names
5
+ # matching `protected` in the settings, and any branch checked out in a worktree.
6
+ class Protection
7
+ NAMES = %w[main master].freeze
8
+
9
+ def initialize(config:, checked_out: [])
10
+ @config = config
11
+ @checked_out = checked_out
12
+ end
13
+
14
+ def protected?(branch)
15
+ branch.current? || branch.default? || NAMES.include?(branch.name) ||
16
+ @config.protected?(branch.name) || @checked_out.include?(branch.name)
17
+ end
18
+ end
19
+ end
data/lib/gitflash/repo.rb CHANGED
@@ -30,9 +30,8 @@ module Gitflash
30
30
  # merged into the default branch (one extra git call).
31
31
  def branches(merged_status: false)
32
32
  merged_names = merged_status ? merged_branch_names : nil
33
- branch_lines.map do |line|
34
- Branch.parse(line, default_branch: default_branch, merged_names: merged_names)
35
- end
33
+ list = branch_lines.map { |line| Branch.parse(line, default_branch:, merged_names:) }
34
+ Ownership.new(bash: @bash).annotate(list)
36
35
  end
37
36
 
38
37
  def current_branch
@@ -20,17 +20,18 @@ module Gitflash
20
20
  }
21
21
  end
22
22
 
23
+ # Parts of the state this restore changes; the state before an undo is saved with the same
24
+ def scope = @snapshot.scope
25
+
23
26
  def empty?
24
27
  plan[:branches].empty? && plan[:head].nil? && !plan[:worktree] && plan[:stashes].empty?
25
28
  end
26
29
 
27
- # Applies the plan; returns nil on success or the failed step's Result
28
- def apply
29
- steps.each do |args|
30
- result = run(*args)
31
- return result unless result.success?
32
- end
33
- nil
30
+ # Applies the plan (`only: :branches` just the branch tips); returns nil on success or the
31
+ # failed step's Result
32
+ def apply(only: nil)
33
+ (only == :branches ? branch_steps : steps).lazy.map { |args| run(*args) }
34
+ .find { |result| !result.success? }
34
35
  end
35
36
 
36
37
  private
@@ -93,9 +93,9 @@ module Gitflash
93
93
  # Tree of the working tree, built in a temporary copy of the index so the real one is untouched
94
94
  def worktree_tree
95
95
  top = git('rev-parse', '--show-toplevel')
96
- Tempfile.create('gitflash-index', File.expand_path(git('rev-parse', '--git-dir'))) do |file|
96
+ Tempfile.create('gitflash-index', git('rev-parse', '--absolute-git-dir')) do |file|
97
97
  file.close
98
- real_index = File.expand_path(git('rev-parse', '--git-path', 'index'))
98
+ real_index = git('rev-parse', '--path-format=absolute', '--git-path', 'index')
99
99
  FileUtils.cp(real_index, file.path) if File.exist?(real_index)
100
100
  env = { 'GIT_INDEX_FILE' => file.path }
101
101
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gitflash
4
- VERSION = '0.6.0'
4
+ VERSION = '0.7.0.beta1'
5
5
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # A checkout of the repository as reported by `git worktree list --porcelain`.
5
+ # `branch` is nil for a detached HEAD. `missing` means the directory is gone (git calls it
6
+ # prunable). `dirty` is nil when it cannot be known (missing directory or bare repository).
7
+ # ahead, behind, merged and owner are those of the branch, nil without a branch.
8
+ Worktree = Data.define(
9
+ :path, :head, :branch, :main, :bare, :locked, :lock_reason, :missing,
10
+ :dirty, :ahead, :behind, :merged, :owner
11
+ ) do
12
+ alias_method :main?, :main
13
+ alias_method :locked?, :locked
14
+ alias_method :missing?, :missing
15
+ alias_method :dirty?, :dirty
16
+
17
+ # Why this worktree must not be removed, nil when it can go. `current` is true for the
18
+ # worktree gitflash runs in. The main checkout and the current one are never removable.
19
+ def removal_blocker(force:, current:)
20
+ return "#{path} is the main checkout" if main?
21
+ return "#{path} is the current worktree" if current
22
+
23
+ in_use_blocker unless force || (missing? && !locked?)
24
+ end
25
+
26
+ def in_use_blocker
27
+ return "#{path} is locked (use --force)" if locked?
28
+
29
+ "#{path} has uncommitted changes (use --force)" if dirty? != false
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+
5
+ module Gitflash
6
+ # `gitflash worktree ...` (alias `wt`): worktrees for parallel agent work
7
+ class WorktreeCli < Thor
8
+ extend Configuration::Descriptions
9
+ include CommandRunner
10
+
11
+ namespace 'worktree'
12
+
13
+ def self.exit_on_failure?
14
+ true
15
+ end
16
+
17
+ desc 'list', 'List worktrees with branch, dirty and merge state'
18
+ long_desc <<~TEXT
19
+ Lists every worktree of the repository: absolute path, branch, HEAD, whether it has
20
+ uncommitted changes, ahead/behind counts, whether its branch is merged into the default
21
+ branch, lock state and whether its directory is missing.
22
+
23
+ Agents use the absolute paths from `--json`; no directory switching is needed.
24
+
25
+ Accepts the global --json option after the subcommand: gitflash wt list --json
26
+ TEXT
27
+ def list
28
+ run_command('worktree list', Commands::WorktreeList)
29
+ end
30
+
31
+ desc 'add BRANCH', 'Add a worktree for a branch (existing, remote or new)'
32
+ long_desc <<~TEXT
33
+ Adds a worktree for BRANCH. An existing local branch is checked out; a branch that exists
34
+ only on origin is created to track it; any other name creates a new branch from HEAD
35
+ (or from --from). New branches are marked as agent work so `gitflash clean` can find them.
36
+
37
+ The directory defaults to ../<repo>.worktrees/<branch> (setting `worktree_dir` in
38
+ .gitflash.yml changes it). The result lists the absolute path: use it, no directory
39
+ switching is needed.
40
+
41
+ Needs no confirmation. Accepts the global --json and --dry-run options after the
42
+ subcommand: gitflash wt add feature --json
43
+ TEXT
44
+ option :path, type: :string, banner: 'DIR', desc: 'Directory for the worktree'
45
+ option :from, type: :string, banner: 'REF', desc: 'Start a new branch from REF (default HEAD)'
46
+ option :owner, type: :string, enum: Ownership::OWNERS,
47
+ desc: 'Mark a new branch as agent (default) or human work'
48
+ def add(branch = nil)
49
+ run_command('worktree add', Commands::WorktreeAdd, *[branch].compact)
50
+ end
51
+
52
+ desc 'lock WORKTREE', 'Lock a worktree so prune, remove and clean leave it alone'
53
+ long_desc 'Locks the worktree given as a path or branch. Add --reason to say why.'
54
+ option :reason, type: :string, desc: 'Why the worktree is locked'
55
+ def lock(worktree = nil)
56
+ run_command('worktree lock', Commands::WorktreeLock, *[worktree].compact)
57
+ end
58
+
59
+ desc 'unlock WORKTREE', 'Unlock a worktree'
60
+ def unlock(worktree = nil)
61
+ run_command('worktree unlock', Commands::WorktreeUnlock, *[worktree].compact)
62
+ end
63
+
64
+ desc 'move WORKTREE PATH', 'Move a worktree to another directory'
65
+ long_desc <<~TEXT
66
+ Moves the worktree (a path or branch) to PATH. The main checkout and the current worktree
67
+ cannot be moved; a locked worktree needs --force.
68
+ TEXT
69
+ option :force, type: :boolean, default: false, desc: 'Also move a locked worktree'
70
+ def move(worktree = nil, destination = nil)
71
+ run_command('worktree move', Commands::WorktreeMove, *[worktree, destination].compact)
72
+ end
73
+
74
+ desc 'prune', 'Forget worktrees whose directory is gone'
75
+ long_desc <<~TEXT
76
+ Removes git's record of worktrees whose directory no longer exists. Locked worktrees stay.
77
+ Branches are not touched. Asks for confirmation; pass --yes to skip it, or --dry-run.
78
+ TEXT
79
+ def prune
80
+ run_command('worktree prune', Commands::WorktreePrune)
81
+ end
82
+
83
+ desc 'clean', 'Remove worktrees whose branch is merged, gone, stale or agent-created'
84
+ long_desc <<~TEXT
85
+ Removes worktrees whose branch matches the criteria of `gitflash clean`: merged into the
86
+ default branch or upstream gone by default, plus --stale [DAYS] and --agent. The main
87
+ checkout, the current worktree and worktrees on protected branches are never removed;
88
+ locked worktrees and worktrees with uncommitted changes are skipped (and listed with the
89
+ reason) unless --force is given.
90
+
91
+ Each removal saves a snapshot first, as `wt remove` does. Branches are kept: run
92
+ `gitflash clean` afterwards to delete them. Missing directories are handled by `wt prune`.
93
+
94
+ Asks for confirmation; pass --yes to skip it, or --dry-run.
95
+ TEXT
96
+ option :merged, type: :boolean, desc: 'Branches merged into the default branch'
97
+ option :gone, type: :boolean, desc: 'Branches whose upstream branch was deleted'
98
+ option :stale, type: :string, banner: 'DAYS', lazy_default: '',
99
+ desc: 'Branches without commits for DAYS days (default: stale_days setting)'
100
+ option :agent, type: :boolean, desc: 'Branches created by an agent'
101
+ option :force, type: :boolean, default: false,
102
+ desc: 'Also remove locked worktrees and worktrees with uncommitted changes'
103
+ def clean
104
+ run_command('worktree clean', Commands::WorktreeClean)
105
+ end
106
+
107
+ desc 'remove [WORKTREE...]', 'Remove worktrees (paths or branch names), saving a snapshot first'
108
+ long_desc descriptions.worktree_remove.long
109
+ option :force, type: :boolean, default: false,
110
+ desc: 'Remove locked worktrees and worktrees with uncommitted changes'
111
+ def remove(*worktrees)
112
+ run_command('worktree remove', Commands::WorktreeRemove, *worktrees)
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Brings back a worktree that was removed after a snapshot of it: adds the worktree again on
5
+ # its branch (or detached HEAD) and restores its files. Branch tips are restored before the
6
+ # worktree is added, so a branch deleted or moved since is checked out at its saved tip and its
7
+ # files always match it.
8
+ class WorktreeRevival
9
+ def initialize(snapshot, repo:)
10
+ @snapshot = snapshot
11
+ @repo = repo
12
+ end
13
+
14
+ # Same shape as an undo plan, plus `recreate_worktree`
15
+ def plan
16
+ head = @snapshot.head
17
+ { branches: Restore.new(@snapshot).plan[:branches],
18
+ head: { from: nil, to: head[:branch] || head[:sha] },
19
+ worktree: !@snapshot.worktree.nil?, stashes: [], snapshot: @snapshot.id,
20
+ recreate_worktree: @snapshot.worktree_path }
21
+ end
22
+
23
+ def scope
24
+ %w[branches]
25
+ end
26
+
27
+ def empty?
28
+ false
29
+ end
30
+
31
+ # Returns nil on success or the failed step's Result
32
+ def apply
33
+ failure = Restore.new(@snapshot).apply(only: :branches)
34
+ return failure if failure
35
+
36
+ result = attach
37
+ return result unless result.success?
38
+
39
+ restore = Restore.new(@snapshot, bash: Git::InDirectory.new(@snapshot.worktree_path))
40
+ restore.apply
41
+ end
42
+
43
+ private
44
+
45
+ def attach
46
+ worktrees = Worktrees.new(repo: @repo)
47
+ path = @snapshot.worktree_path
48
+ branch = @snapshot.head[:branch]
49
+ sha = @snapshot.head[:sha]
50
+ return worktrees.attach(path, detach_at: sha) unless branch
51
+
52
+ worktrees.attach(path, branch: branch, create_at: sha)
53
+ end
54
+ end
55
+ end