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
@@ -4,35 +4,43 @@ require 'fileutils'
4
4
 
5
5
  module Gitflash
6
6
  module Commands
7
- # Registers `gitflash hook claude` as a Claude Code PreToolUse hook for Bash commands.
8
- # Merges into the settings file and keeps everything else in it.
7
+ # Registers `gitflash hook claude` as a Claude Code hook for Bash commands: PreToolUse
8
+ # (snapshots before destructive git commands) and PostToolUse (marks branches an agent
9
+ # created). Merges into the settings file and keeps everything else in it.
9
10
  class HookInstall < Base
10
11
  SCOPES = Hook::Settings::SCOPES
12
+ MARKING_COMMAND = 'gitflash hook claude'
11
13
 
12
14
  def call
13
15
  plan = build_plan
14
16
  data = settings.read(plan[:file])
15
17
  entry = settings.entry(data)
16
- return already_installed(plan) if entry && entry['command'] == plan[:command]
18
+ marking = settings.entry(data, Hook::Claude::POST_EVENT)
19
+ return already_installed(plan) if up_to_date?(entry, marking, plan)
17
20
  return planned(plan, "Would add the gitflash hook to #{plan[:file]}") if ui.dry_run?
18
21
 
19
- install(data, entry, plan)
22
+ install(data, entry, marking, plan)
20
23
  end
21
24
 
22
25
  private
23
26
 
27
+ def up_to_date?(entry, marking, plan)
28
+ entry && entry['command'] == plan[:command] && marking
29
+ end
30
+
24
31
  def build_plan
25
32
  scope = choice(:scope, SCOPES, 'local')
26
33
  { scope: scope, file: settings.path(scope), command: hook_command,
27
- applies_to: settings.applies_to(scope) }
34
+ marking_command: MARKING_COMMAND, applies_to: settings.applies_to(scope) }
28
35
  end
29
36
 
30
37
  def settings
31
38
  @settings ||= Hook::Settings.new(options[:scope] == 'user' ? nil : repo)
32
39
  end
33
40
 
34
- def install(data, entry, plan)
35
- entry ? entry['command'] = plan[:command] : add_entry(data, plan[:command])
41
+ def install(data, entry, marking, plan)
42
+ entry ? entry['command'] = plan[:command] : add_entry(data, 'PreToolUse', plan[:command])
43
+ add_entry(data, Hook::Claude::POST_EVENT, MARKING_COMMAND) unless marking
36
44
  FileUtils.mkdir_p(File.dirname(plan[:file]))
37
45
  File.write(plan[:file], "#{JSON.pretty_generate(data)}\n")
38
46
  ui.report(status: 'done', plan: plan, result: plan,
@@ -57,9 +65,9 @@ module Gitflash
57
65
  text: "Already installed in #{plan[:file]}.\nIt applies to #{plan[:applies_to]}.")
58
66
  end
59
67
 
60
- def add_entry(data, command)
68
+ def add_entry(data, event, command)
61
69
  hooks = (data['hooks'] ||= {})
62
- (hooks['PreToolUse'] ||= []) << {
70
+ (hooks[event] ||= []) << {
63
71
  'matcher' => 'Bash',
64
72
  'hooks' => [{ 'type' => 'command', 'command' => command, 'timeout' => 30 }]
65
73
  }
@@ -9,9 +9,7 @@ module Gitflash
9
9
  def call
10
10
  settings = Hook::Settings.new(inside_repo? ? repo : nil)
11
11
  files = settings.scopes.map { |scope| file_status(settings, scope) }
12
- executable = find_executable
13
- active = executable && files.any? { |file| file[:installed] }
14
- result = { active: active ? true : false, executable: executable, files: files }
12
+ result = summary(files, find_executable)
15
13
  ui.report(status: 'done', result: result, text: text(result))
16
14
  end
17
15
 
@@ -24,10 +22,19 @@ module Gitflash
24
22
  false
25
23
  end
26
24
 
25
+ def summary(files, executable)
26
+ installed = files.select { |file| file[:installed] }
27
+ active = executable && installed.any?
28
+ { active: active ? true : false, marking: active && installed.any? { |f| f[:marking] },
29
+ executable: executable, files: files }
30
+ end
31
+
27
32
  def file_status(settings, scope)
28
33
  path = settings.path(scope)
29
- entry = settings.entry(settings.read(path))
34
+ data = settings.read(path)
35
+ entry = settings.entry(data)
30
36
  { scope: scope, file: path, installed: !entry.nil?, command: entry&.fetch('command', nil),
37
+ marking: !settings.entry(data, Hook::Claude::POST_EVENT).nil?,
31
38
  applies_to: settings.applies_to(scope) }
32
39
  end
33
40
 
@@ -39,7 +46,7 @@ module Gitflash
39
46
  def text(result)
40
47
  lines = [headline(result)]
41
48
  result[:files].each do |file|
42
- state = file[:installed] ? "installed (#{file[:command]})" : 'not installed'
49
+ state = file[:installed] ? installed_text(file) : 'not installed'
43
50
  lines << " #{file[:scope].ljust(8)} #{file[:file]}: #{state}"
44
51
  lines << " applies to #{file[:applies_to]}" if file[:installed]
45
52
  end
@@ -47,8 +54,13 @@ module Gitflash
47
54
  lines.join("\n")
48
55
  end
49
56
 
57
+ def installed_text(file)
58
+ note = file[:marking] ? ', marks agent branches' : ', branch marking off'
59
+ "installed (#{file[:command]})#{note}"
60
+ end
61
+
50
62
  def headline(result)
51
- return 'gitflash hook: active' if result[:active]
63
+ return active_headline(result) if result[:active]
52
64
 
53
65
  reason = if result[:executable]
54
66
  'run `gitflash hook install`'
@@ -57,6 +69,12 @@ module Gitflash
57
69
  end
58
70
  "gitflash hook: not active (#{reason})"
59
71
  end
72
+
73
+ def active_headline(result)
74
+ return 'gitflash hook: active, marks agent branches' if result[:marking]
75
+
76
+ 'gitflash hook: active (branch marking off: run `gitflash hook install` again)'
77
+ end
60
78
  end
61
79
  end
62
80
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Marks branches as agent or human work (`--owner`, default agent) or removes the mark
6
+ # (`--clear`), so `gitflash clean --agent` knows which branches an agent created.
7
+ # Without branch names the current branch is marked. Needs no confirmation: only a git
8
+ # config key changes, and `--clear` or another `mark` reverses it.
9
+ class Mark < Base
10
+ def call(*names)
11
+ names = names.uniq
12
+ names = [current_branch!] if names.empty?
13
+ check_branches!(names)
14
+
15
+ previous = ownership.all
16
+ plan = { branches: names, owner: owner }
17
+ return planned(plan, "Would #{verb} #{names.join(', ')}") if ui.dry_run?
18
+
19
+ apply(plan, previous)
20
+ end
21
+
22
+ private
23
+
24
+ def ownership
25
+ @ownership ||= Ownership.new
26
+ end
27
+
28
+ # The owner to set, nil for --clear
29
+ def owner
30
+ given = options[:owner]
31
+ if options[:clear]
32
+ usage_error!('invalid_options', 'Use either --owner or --clear, not both') if given
33
+ return nil
34
+ end
35
+ return 'agent' if given.nil?
36
+ return given if Ownership::OWNERS.include?(given)
37
+
38
+ usage_error!('invalid_options', "Unknown owner '#{given}' (use agent or human)")
39
+ end
40
+
41
+ def verb
42
+ options[:clear] ? 'clear the mark of' : "mark as #{owner}"
43
+ end
44
+
45
+ def current_branch!
46
+ repo.current_branch || usage_error!('invalid_usage',
47
+ 'HEAD is detached: pass the branch to mark')
48
+ end
49
+
50
+ def check_branches!(names)
51
+ known = repo.branches.map(&:name)
52
+ unknown = names.reject { |name| known.include?(name) }
53
+ usage_error!('unknown_branch', "Unknown branch: #{unknown.join(', ')}") if unknown.any?
54
+ end
55
+
56
+ def apply(plan, previous)
57
+ plan[:branches].each { |name| write(name, plan[:owner]) }
58
+ rows = plan[:branches].map { |name| row(name, plan[:owner], previous[name]) }
59
+ status = rows.all? { |entry| entry[:owner] == entry[:previous] } ? 'noop' : 'done'
60
+ ui.report(status: status, plan: plan, result: { branches: rows }, text: text(rows))
61
+ end
62
+
63
+ def write(name, owner)
64
+ owner ? ownership.mark(name, owner) : ownership.clear(name)
65
+ end
66
+
67
+ def row(name, owner, previous)
68
+ { branch: name, owner: owner, previous: previous }
69
+ end
70
+
71
+ def text(rows)
72
+ rows.map { |row| line(row) }.join("\n")
73
+ end
74
+
75
+ def line(row)
76
+ if row[:owner] == row[:previous]
77
+ return "#{row[:branch]} is already #{row[:owner] || 'unmarked'}"
78
+ end
79
+ return "Cleared the mark of #{row[:branch]} (was #{row[:previous]})" unless row[:owner]
80
+
81
+ "Marked #{row[:branch]} as #{row[:owner]}"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Cherry-picks commits from another branch. `--list` shows the commits of SOURCE whose change
6
+ # is not on the current branch yet (commits with an equivalent patch already here are marked
7
+ # `applied` and never picked). With SHAs, or chosen from a menu in a terminal, it applies them
8
+ # oldest first with `git cherry-pick -x` after saving a snapshot; `gitflash undo` returns to
9
+ # the starting point. On a conflict it stops and lists the files: see PickControl.
10
+ class Pick < Base
11
+ include PickOutcome
12
+
13
+ NOTHING = 'Nothing to pick: the changes are already on the current branch'
14
+
15
+ # --continue, --skip and --abort are handled by PickControl, everything else by Pick
16
+ def self.command_for(options)
17
+ PickControl::ACTIONS.any? { |action| options[action.to_sym] } ? PickControl : self
18
+ end
19
+
20
+ def call(source = nil, *shas)
21
+ usage_error!('input_required', 'Pass the source branch: gitflash pick SOURCE') unless source
22
+ check_idle!
23
+ candidates = cherry_pick.candidates(source_ref(source))
24
+ return list(source, candidates) if options[:list]
25
+
26
+ wanted = shas.empty? ? pick_from_menu(candidates) : shas
27
+ return 0 if wanted.nil?
28
+
29
+ pick(source, select_candidates(source, candidates, wanted))
30
+ end
31
+
32
+ private
33
+
34
+ def check_idle!
35
+ return unless cherry_pick.in_progress?
36
+
37
+ usage_error!('invalid_usage', 'A cherry-pick is already in progress: use gitflash pick ' \
38
+ '--continue, --skip or --abort')
39
+ end
40
+
41
+ def source_ref(source)
42
+ ref = cherry_pick.source_ref(source)
43
+ ref || usage_error!('unknown_branch', "Unknown branch '#{source}'")
44
+ end
45
+
46
+ def list(source, candidates)
47
+ ui.report(status: 'done', result: { source: source, commits: candidates.map(&:to_h) },
48
+ text: table(candidates))
49
+ end
50
+
51
+ def table(candidates)
52
+ return 'No commits to pick.' if candidates.empty?
53
+
54
+ candidates.map { |candidate| row(candidate) }.join("\n")
55
+ end
56
+
57
+ def row(candidate)
58
+ stat = "+#{candidate.insertions} -#{candidate.deletions}"
59
+ "#{candidate.applied ? '=' : ' '} #{candidate.short_sha} " \
60
+ "#{TerminalText.safe(candidate.subject)} (#{TerminalText.safe(candidate.author)}, " \
61
+ "#{candidate.date[0, 10]}, #{candidate.files} files #{stat})"
62
+ end
63
+
64
+ # SHAs picked from a menu, nil after telling the user there is nothing to pick
65
+ def pick_from_menu(candidates)
66
+ require_interactive!('Pass the commits to pick: gitflash pick SOURCE SHA...')
67
+ fresh = candidates.reject(&:applied)
68
+ return nothing_chosen('No commits to pick') if fresh.empty?
69
+
70
+ chosen = ui.multi_select('Select commits to pick (oldest first)',
71
+ fresh.to_h { |candidate| [row(candidate), candidate.sha] })
72
+ chosen.empty? ? nothing_chosen('No commits selected') : chosen
73
+ end
74
+
75
+ def nothing_chosen(text)
76
+ report_nothing(text)
77
+ nil
78
+ end
79
+
80
+ # The asked-for commits in source order (oldest first), whatever order they were given in
81
+ def select_candidates(source, candidates, wanted)
82
+ found = wanted.map { |arg| find(arg, candidates, source) }
83
+ candidates.select { |candidate| found.include?(candidate) }
84
+ end
85
+
86
+ def find(arg, candidates, source)
87
+ matches = candidates.select { |candidate| candidate.sha.start_with?(arg.to_s) }
88
+ return matches.first if arg.to_s.length >= 4 && matches.size == 1
89
+
90
+ usage_error!('unknown_commit', "'#{arg}' is not a commit of #{source} that is missing " \
91
+ 'from the current branch (or it is ambiguous)')
92
+ end
93
+
94
+ def pick(source, chosen)
95
+ fresh, applied = chosen.partition { |candidate| !candidate.applied }
96
+ return report_nothing(NOTHING) if fresh.empty?
97
+
98
+ plan = build_plan(source, fresh, applied)
99
+ return planned(plan, "Would pick:\n#{bullets(plan)}") if ui.dry_run?
100
+ return cancelled(plan) unless confirm?(plan)
101
+
102
+ run(plan)
103
+ end
104
+
105
+ def build_plan(source, fresh, applied)
106
+ skipped = applied.map { |c| { sha: c.sha, reason: 'already on the current branch' } }
107
+ { source: source, commits: fresh.map(&:sha), skipped: skipped,
108
+ commit: options[:commit] != false }
109
+ end
110
+
111
+ def confirm?(plan)
112
+ ui.confirm?("You are about to pick:\n\n#{bullets(plan)}", plan: plan)
113
+ end
114
+
115
+ def run(plan)
116
+ snapshot = take_snapshot("gitflash pick #{plan[:source]}",
117
+ scope: %w[branches head worktree], branches: current_branch_names)
118
+ result = cherry_pick.apply(plan[:commits], commit: plan[:commit])
119
+ outcome(plan, result, plan[:commits], snapshot)
120
+ end
121
+
122
+ def bullets(plan)
123
+ plan[:commits].map { |sha| "* #{sha[0, 7]}" }.join("\n")
124
+ end
125
+
126
+ def report_nothing(text)
127
+ ui.report(status: 'noop', result: { applied: [] }, text: text)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # `gitflash pick --continue`, `--skip` or `--abort`: wraps git's own cherry-pick controls for
6
+ # a pick that stopped on conflicts. --skip and --abort discard work in progress, so they ask
7
+ # for confirmation and save a snapshot first; --continue adds to the work and does not.
8
+ class PickControl < Base
9
+ include PickOutcome
10
+
11
+ ACTIONS = %w[continue skip abort].freeze
12
+
13
+ def call(*)
14
+ action = chosen_action
15
+ usage_error!('invalid_usage', 'No cherry-pick in progress') unless cherry_pick.in_progress?
16
+
17
+ plan = build_plan(action)
18
+ return perform(action, plan, nil) if action == 'continue'
19
+ return planned(plan, "Would #{action} the cherry-pick in progress") if ui.dry_run?
20
+ return cancelled(plan) unless ui.confirm?(summary(action), plan: plan)
21
+
22
+ perform(action, plan, take_snapshot("gitflash pick --#{action}", scope: %w[head worktree]))
23
+ end
24
+
25
+ private
26
+
27
+ def chosen_action
28
+ given = ACTIONS.select { |action| options[action.to_sym] }
29
+ return given.first if given.one?
30
+
31
+ usage_error!('invalid_options', 'Use exactly one of --continue, --skip or --abort')
32
+ end
33
+
34
+ def build_plan(action)
35
+ progress = cherry_pick.progress
36
+ { action: action, current: progress.current, conflicted: progress.conflicted,
37
+ remaining: progress.remaining }
38
+ end
39
+
40
+ def summary(action)
41
+ "You are about to #{action} the cherry-pick in progress; " \
42
+ 'the conflict resolution made so far is discarded.'
43
+ end
44
+
45
+ def perform(action, plan, snapshot)
46
+ result = cherry_pick.public_send(action == 'continue' ? :continue : action.to_sym)
47
+ return outcome_after(action, plan, result, snapshot) if action != 'abort'
48
+
49
+ git_error!('cherry-pick --abort', result) unless result.success?
50
+ finish(action, plan, snapshot)
51
+ end
52
+
53
+ def outcome_after(action, plan, result, snapshot)
54
+ return conflict(plan, result, [], snapshot) if cherry_pick.in_progress?
55
+
56
+ git_error!("cherry-pick --#{action}", result) unless result.success?
57
+ finish(action, plan, snapshot)
58
+ end
59
+
60
+ def finish(action, plan, snapshot)
61
+ head = repo.resolve_commit('HEAD')
62
+ ui.report(status: 'done', plan: plan, result: { action: action, head: head },
63
+ undo: snapshot, text: "Cherry-pick #{action} done, now at #{head[0, 7]}")
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # How `pick` and its --continue / --skip / --abort report a git cherry-pick run: done, or
6
+ # stopped on conflicts (status `failed`, error code `conflict`) with what is left to do.
7
+ module PickOutcome
8
+ private
9
+
10
+ def cherry_pick
11
+ @cherry_pick ||= CherryPick.new
12
+ end
13
+
14
+ # `requested` are the commits the user asked for, in order; `snapshot` reverts the start
15
+ def outcome(plan, result, requested, snapshot)
16
+ return finished(plan, requested, snapshot) if result.success? && !cherry_pick.in_progress?
17
+ return conflict(plan, result, requested, snapshot) if cherry_pick.in_progress?
18
+
19
+ git_error!('cherry-pick', result)
20
+ end
21
+
22
+ def finished(plan, requested, snapshot)
23
+ head = repo.resolve_commit('HEAD')
24
+ text = "Applied #{requested.size} commit(s), now at #{head[0, 7]}"
25
+ ui.report(status: 'done', plan: plan, result: { applied: requested, head: head },
26
+ undo: snapshot, text: text)
27
+ end
28
+
29
+ def conflict(plan, result, requested, snapshot)
30
+ progress = cherry_pick.progress
31
+ text = conflict_text(progress, result)
32
+ details = { applied: requested - progress.remaining - [progress.current],
33
+ current: progress.current, conflicted: progress.conflicted,
34
+ remaining: progress.remaining }
35
+ ui.report(status: 'failed', plan: plan, result: details, undo: snapshot, text: text,
36
+ error: { code: 'conflict', exit_code: 1, message: text })
37
+ end
38
+
39
+ def conflict_text(progress, result)
40
+ files = progress.conflicted.map { |file| "* #{file}" }.join("\n")
41
+ "Stopped on conflicts in:\n#{files}\n#{result.output}\n" \
42
+ 'Resolve them and stage the files, then run `gitflash pick --continue`; ' \
43
+ 'or `gitflash pick --skip` / `gitflash pick --abort`.'
44
+ end
45
+
46
+ def current_branch_names
47
+ branch = repo.current_branch
48
+ branch ? [branch] : []
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Reports the outcome of removing worktrees: one row per worktree with its own snapshot and
6
+ # undo command, failures with their reason. Used by `wt remove` and `wt clean`.
7
+ module RemovalReport
8
+ # What happened to one worktree; `snapshot` is nil when it was not removed
9
+ Removal = Data.define(:worktree, :result, :snapshot)
10
+
11
+ private
12
+
13
+ # Ignored files are never saved by snapshots, so `undo` cannot bring them back
14
+ def ignored_warning(plan)
15
+ rows = plan[:worktrees].select { |row| row[:ignored_count].positive? }
16
+ return '' if rows.empty?
17
+
18
+ lines = rows.map { |row| ignored_line(row) }
19
+ "\n\nIgnored files are not saved by snapshots and will be lost for good:\n" \
20
+ "#{lines.join("\n")}"
21
+ end
22
+
23
+ def ignored_line(row)
24
+ "* #{row[:path]}: #{row[:ignored_count]} (#{row[:ignored].join(', ')})"
25
+ end
26
+
27
+ def report_results(plan, outcomes)
28
+ removed, failed = outcomes.partition { |outcome| outcome.result.success? }
29
+ result = { removed: removed.map { |outcome| removed_row(outcome) },
30
+ failed: failed.map { |outcome| failed_row(outcome) } }
31
+ ui.report(status: failed.empty? ? 'done' : 'failed', plan: plan, result: result,
32
+ error: failure(failed, outcomes), text: results_text(result),
33
+ undo: single_undo(removed))
34
+ end
35
+
36
+ # `undo` in the envelope only when one worktree was removed; each row has its own
37
+ def single_undo(removed)
38
+ removed.first.snapshot if removed.size == 1
39
+ end
40
+
41
+ def failed_row(outcome)
42
+ { path: outcome.worktree.path, error: outcome.result.output }
43
+ end
44
+
45
+ def removed_row(outcome)
46
+ worktree = outcome.worktree
47
+ { path: worktree.path, branch: worktree.branch, head: worktree.head,
48
+ snapshot: outcome.snapshot.id, undo: "gitflash undo #{outcome.snapshot.id}" }
49
+ end
50
+
51
+ def failure(failed, outcomes)
52
+ return nil if failed.empty?
53
+
54
+ { code: 'git_failed', exit_code: 1,
55
+ message: "#{failed.size} of #{outcomes.size} worktrees were not removed" }
56
+ end
57
+
58
+ 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]}" }
61
+ lines.join("\n")
62
+ end
63
+ end
64
+ end
65
+ end
@@ -9,8 +9,7 @@ module Gitflash
9
9
  snapshot = find(id)
10
10
  return no_snapshots unless snapshot
11
11
 
12
- check_worktree!(snapshot)
13
- restore = Restore.new(snapshot)
12
+ restore = build_restore(snapshot)
14
13
  plan = restore.plan.merge(snapshot: snapshot.id)
15
14
  return nothing_to_undo(plan) if restore.empty?
16
15
 
@@ -25,6 +24,20 @@ module Gitflash
25
24
  snapshots.find(id) || usage_error!('unknown_snapshot', "Unknown snapshot '#{id}'")
26
25
  end
27
26
 
27
+ # A snapshot of a worktree that is gone is restored by adding the worktree again
28
+ def build_restore(snapshot)
29
+ return WorktreeRevival.new(snapshot, repo: repo) if removed_worktree?(snapshot)
30
+
31
+ check_worktree!(snapshot)
32
+ Restore.new(snapshot)
33
+ end
34
+
35
+ # The snapshot was taken in a worktree whose directory no longer exists
36
+ def removed_worktree?(snapshot)
37
+ (snapshot.scope?('head') || snapshot.scope?('worktree')) &&
38
+ snapshot.worktree_path != repo.toplevel && !File.exist?(snapshot.worktree_path)
39
+ end
40
+
28
41
  # Branches are shared between worktrees; HEAD and files belong to one worktree
29
42
  def check_worktree!(snapshot)
30
43
  return unless snapshot.scope?('head') || snapshot.scope?('worktree')
@@ -43,21 +56,34 @@ module Gitflash
43
56
  end
44
57
 
45
58
  def apply(snapshot, restore, plan)
46
- before = snapshots.create(reason: "before undo #{snapshot.id}", scope: snapshot.scope,
59
+ before = snapshots.create(reason: "before undo #{snapshot.id}", scope: restore.scope,
47
60
  branches: snapshot.branches.keys)
48
61
  failure = restore.apply
49
62
  raise_failure(failure, before, plan) if failure
50
63
 
64
+ report_done(snapshot, plan, before)
65
+ end
66
+
67
+ def report_done(snapshot, plan, before)
51
68
  ui.report(status: 'done', plan: plan, result: { snapshot: snapshot.id }, undo: before,
52
69
  text: "Restored snapshot #{snapshot.id}:\n#{describe(plan)}")
53
70
  end
54
71
 
55
72
  def raise_failure(failure, before, plan)
56
73
  message = "gitflash undo failed:\n#{failure.output}\n" \
57
- "The state before this undo is saved as snapshot #{before.id}."
74
+ "The state before this undo is saved as snapshot #{before.id}." \
75
+ "#{revival_hint(plan)}"
58
76
  raise Error.new(message, code: 'git_failed', plan: plan)
59
77
  end
60
78
 
79
+ # That snapshot cannot take the added worktree away again
80
+ def revival_hint(plan)
81
+ return '' unless plan[:recreate_worktree]
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"
85
+ end
86
+
61
87
  def no_snapshots
62
88
  ui.report(status: 'noop', result: { snapshot: nil }, text: 'No snapshots to undo')
63
89
  end
@@ -74,10 +100,16 @@ module Gitflash
74
100
 
75
101
  def describe(plan)
76
102
  lines = plan[:branches].map { |change| branch_line(change) }
77
- lines << head_line(plan[:head]) if plan[:head]
103
+ lines << "* add worktree #{plan[:recreate_worktree]} again" if plan[:recreate_worktree]
104
+ lines << head_line(plan[:head]) if plan[:head] && !plan[:recreate_worktree]
105
+ (lines + file_lines(plan)).join("\n")
106
+ end
107
+
108
+ def file_lines(plan)
109
+ lines = []
78
110
  lines << '* restore tracked, staged and untracked files' if plan[:worktree]
79
111
  lines << "* restore #{plan[:stashes].size} stash entries" if plan[:stashes].any?
80
- lines.join("\n")
112
+ lines
81
113
  end
82
114
 
83
115
  def branch_line(change)