gitflash 0.5.1 → 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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +69 -2
  4. data/bin/gitflash +10 -0
  5. data/command_descriptions.yml +99 -0
  6. data/lib/gitflash/branch.rb +14 -2
  7. data/lib/gitflash/cherry_pick.rb +128 -0
  8. data/lib/gitflash/cleanup.rb +70 -0
  9. data/lib/gitflash/cli.rb +72 -21
  10. data/lib/gitflash/command_runner.rb +23 -0
  11. data/lib/gitflash/commands/base.rb +15 -0
  12. data/lib/gitflash/commands/branches.rb +1 -0
  13. data/lib/gitflash/commands/checkout.rb +2 -1
  14. data/lib/gitflash/commands/clean.rb +51 -0
  15. data/lib/gitflash/commands/clean_criteria.rb +29 -0
  16. data/lib/gitflash/commands/delete.rb +23 -8
  17. data/lib/gitflash/commands/gc.rb +41 -0
  18. data/lib/gitflash/commands/hook_install.rb +77 -0
  19. data/lib/gitflash/commands/hook_status.rb +80 -0
  20. data/lib/gitflash/commands/mark.rb +85 -0
  21. data/lib/gitflash/commands/pick.rb +131 -0
  22. data/lib/gitflash/commands/pick_control.rb +67 -0
  23. data/lib/gitflash/commands/pick_outcome.rb +52 -0
  24. data/lib/gitflash/commands/removal_report.rb +65 -0
  25. data/lib/gitflash/commands/reset.rb +7 -1
  26. data/lib/gitflash/commands/snapshot_create.rb +30 -0
  27. data/lib/gitflash/commands/snapshot_list.rb +20 -0
  28. data/lib/gitflash/commands/undo.rb +131 -0
  29. data/lib/gitflash/commands/worktree_add.rb +98 -0
  30. data/lib/gitflash/commands/worktree_clean.rb +49 -0
  31. data/lib/gitflash/commands/worktree_command.rb +28 -0
  32. data/lib/gitflash/commands/worktree_list.rb +48 -0
  33. data/lib/gitflash/commands/worktree_lock.rb +54 -0
  34. data/lib/gitflash/commands/worktree_move.rb +47 -0
  35. data/lib/gitflash/commands/worktree_prune.rb +41 -0
  36. data/lib/gitflash/commands/worktree_remove.rb +119 -0
  37. data/lib/gitflash/commands/worktree_unlock.rb +12 -0
  38. data/lib/gitflash/config.rb +95 -0
  39. data/lib/gitflash/config_keys.rb +19 -0
  40. data/lib/gitflash/git/bash_command.rb +5 -4
  41. data/lib/gitflash/git/in_directory.rb +29 -0
  42. data/lib/gitflash/hook/branch_marker.rb +52 -0
  43. data/lib/gitflash/hook/claude.rb +126 -0
  44. data/lib/gitflash/hook/command_parser.rb +121 -0
  45. data/lib/gitflash/hook/creations.rb +85 -0
  46. data/lib/gitflash/hook/rules.rb +108 -0
  47. data/lib/gitflash/hook/settings.rb +73 -0
  48. data/lib/gitflash/hook_cli.rb +77 -0
  49. data/lib/gitflash/ownership.rb +59 -0
  50. data/lib/gitflash/protection.rb +19 -0
  51. data/lib/gitflash/repo.rb +12 -3
  52. data/lib/gitflash/restore.rb +141 -0
  53. data/lib/gitflash/snapshot.rb +47 -0
  54. data/lib/gitflash/snapshots.rb +70 -0
  55. data/lib/gitflash/state_capture.rb +129 -0
  56. data/lib/gitflash/ui.rb +7 -5
  57. data/lib/gitflash/version.rb +1 -1
  58. data/lib/gitflash/worktree.rb +32 -0
  59. data/lib/gitflash/worktree_cli.rb +115 -0
  60. data/lib/gitflash/worktree_revival.rb +55 -0
  61. data/lib/gitflash/worktrees.rb +147 -0
  62. data/schema/v1.json +1885 -102
  63. metadata +46 -1
@@ -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
@@ -48,11 +48,17 @@ module Gitflash
48
48
 
49
49
  def execute(plan, text)
50
50
  previous = repo.resolve_commit('HEAD')
51
+ snapshot = snapshot_before(plan)
51
52
  result = repo.reset(plan[:commit], mode: plan[:mode])
52
53
  git_error!('reset', result) unless result.success?
53
54
 
54
55
  ui.report(status: 'done', plan: plan, result: plan.merge(previous_commit: previous),
55
- text: text.capitalize)
56
+ undo: snapshot, text: text.capitalize)
57
+ end
58
+
59
+ def snapshot_before(plan)
60
+ take_snapshot("gitflash reset --#{plan[:mode]} #{plan[:commit][0, 7]}",
61
+ scope: %w[branches head worktree], branches: [repo.current_branch].compact)
56
62
  end
57
63
 
58
64
  def confirmed?(plan)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Saves the current state on request, for example before a risky step
6
+ class SnapshotCreate < Base
7
+ def call
8
+ snapshot = take_snapshot(options[:message] || 'gitflash snapshot', scope: scope)
9
+ ui.report(status: 'done', result: { snapshot: snapshot.to_h }, undo: snapshot,
10
+ text: text(snapshot))
11
+ end
12
+
13
+ private
14
+
15
+ def scope
16
+ scope = options[:scope] || Snapshots::SCOPES
17
+ invalid = scope - Snapshots::SCOPES
18
+ usage_error!('invalid_options', "Unknown scope: #{invalid.join(', ')}") if invalid.any?
19
+ scope
20
+ end
21
+
22
+ def text(snapshot)
23
+ text = "Saved snapshot #{snapshot.id}"
24
+ return text if snapshot.skipped_files.empty?
25
+
26
+ "#{text}\nSkipped large untracked files: #{snapshot.skipped_files.join(', ')}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Lists snapshots, newest first
6
+ class SnapshotList < Base
7
+ def call
8
+ list = snapshots.list
9
+ text = list.empty? ? 'No snapshots' : list.map { |snapshot| row(snapshot) }.join("\n")
10
+ ui.report(status: 'done', result: { snapshots: list.map(&:to_h) }, text: text)
11
+ end
12
+
13
+ private
14
+
15
+ def row(snapshot)
16
+ "#{snapshot.id} #{snapshot.scope.join(',').ljust(28)} #{snapshot.reason}"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Restores a snapshot (the latest one by default). The current state is saved first,
6
+ # so an undo can itself be undone.
7
+ class Undo < Base
8
+ def call(id = nil)
9
+ snapshot = find(id)
10
+ return no_snapshots unless snapshot
11
+
12
+ restore = build_restore(snapshot)
13
+ plan = restore.plan.merge(snapshot: snapshot.id)
14
+ return nothing_to_undo(plan) if restore.empty?
15
+
16
+ restore!(snapshot, restore, plan)
17
+ end
18
+
19
+ private
20
+
21
+ def find(id)
22
+ return snapshots.list.first if id.nil?
23
+
24
+ snapshots.find(id) || usage_error!('unknown_snapshot', "Unknown snapshot '#{id}'")
25
+ end
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
+
41
+ # Branches are shared between worktrees; HEAD and files belong to one worktree
42
+ def check_worktree!(snapshot)
43
+ return unless snapshot.scope?('head') || snapshot.scope?('worktree')
44
+ return if snapshot.worktree_path == repo.toplevel
45
+
46
+ usage_error!('wrong_worktree', "Snapshot #{snapshot.id} was taken in " \
47
+ "#{snapshot.worktree_path}; run gitflash undo there")
48
+ end
49
+
50
+ def restore!(snapshot, restore, plan)
51
+ text = "snapshot #{snapshot.id}:\n#{describe(plan)}"
52
+ return planned(plan, "Would restore #{text}") if ui.dry_run?
53
+ return cancelled(plan) unless ui.confirm?(summary(snapshot, plan), plan: plan)
54
+
55
+ apply(snapshot, restore, plan)
56
+ end
57
+
58
+ def apply(snapshot, restore, plan)
59
+ before = snapshots.create(reason: "before undo #{snapshot.id}", scope: restore.scope,
60
+ branches: snapshot.branches.keys)
61
+ failure = restore.apply
62
+ raise_failure(failure, before, plan) if failure
63
+
64
+ report_done(snapshot, plan, before)
65
+ end
66
+
67
+ def report_done(snapshot, plan, before)
68
+ ui.report(status: 'done', plan: plan, result: { snapshot: snapshot.id }, undo: before,
69
+ text: "Restored snapshot #{snapshot.id}:\n#{describe(plan)}")
70
+ end
71
+
72
+ def raise_failure(failure, before, plan)
73
+ message = "gitflash undo failed:\n#{failure.output}\n" \
74
+ "The state before this undo is saved as snapshot #{before.id}." \
75
+ "#{revival_hint(plan)}"
76
+ raise Error.new(message, code: 'git_failed', plan: plan)
77
+ end
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
+
87
+ def no_snapshots
88
+ ui.report(status: 'noop', result: { snapshot: nil }, text: 'No snapshots to undo')
89
+ end
90
+
91
+ def nothing_to_undo(plan)
92
+ text = "Nothing to undo: the repository already matches snapshot #{plan[:snapshot]}"
93
+ ui.report(status: 'noop', plan: plan, result: { snapshot: plan[:snapshot] }, text: text)
94
+ end
95
+
96
+ def summary(snapshot, plan)
97
+ "You are about to restore snapshot #{snapshot.id} " \
98
+ "(#{snapshot.reason}, #{snapshot.created_at}):\n#{describe(plan)}"
99
+ end
100
+
101
+ def describe(plan)
102
+ lines = plan[:branches].map { |change| branch_line(change) }
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 = []
110
+ lines << '* restore tracked, staged and untracked files' if plan[:worktree]
111
+ lines << "* restore #{plan[:stashes].size} stash entries" if plan[:stashes].any?
112
+ lines
113
+ end
114
+
115
+ def branch_line(change)
116
+ target = change[:to][0, 7]
117
+ return "* recreate #{change[:branch]} at #{target}" unless change[:from]
118
+
119
+ "* move #{change[:branch]} back to #{target}"
120
+ end
121
+
122
+ def head_line(head)
123
+ "* switch HEAD from #{short(head[:from])} to #{short(head[:to])}"
124
+ end
125
+
126
+ def short(ref)
127
+ ref.to_s.match?(/\A\h{40}\z/) ? ref[0, 7] : ref.to_s
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Adds a worktree for an existing, remote or new branch. Additive, so it needs no
6
+ # confirmation and no snapshot. A branch created here is marked as agent work.
7
+ class WorktreeAdd < Base
8
+ def call(branch = nil)
9
+ validate_name!(branch)
10
+ worktrees = Worktrees.new(repo: repo)
11
+ source = worktrees.source_for(branch)
12
+ check_options!(source)
13
+ check_free!(worktrees, branch, source)
14
+
15
+ plan = build_plan(branch, source)
16
+ if ui.dry_run?
17
+ return planned(plan,
18
+ "Would add worktree #{plan[:path]} on #{branch} (#{source})")
19
+ end
20
+
21
+ add(worktrees, plan)
22
+ end
23
+
24
+ private
25
+
26
+ def validate_name!(branch)
27
+ usage_error!('invalid_usage', 'Pass the branch: gitflash wt add BRANCH') if branch.nil?
28
+ valid = !branch.start_with?('-') && ref_format_ok?(branch)
29
+ usage_error!('invalid_usage', "Invalid branch name '#{branch}'") unless valid
30
+ end
31
+
32
+ def ref_format_ok?(branch)
33
+ _stdout, _stderr, success = Git::BashCommand.capture('git', 'check-ref-format', '--branch',
34
+ branch)
35
+ success
36
+ end
37
+
38
+ def check_options!(source)
39
+ return unless options[:from] && source != :new
40
+
41
+ usage_error!('invalid_options', '--from only applies to a new branch, but the branch ' \
42
+ "already exists (#{source})")
43
+ end
44
+
45
+ def check_free!(worktrees, branch, source)
46
+ return unless source == :existing
47
+
48
+ holder = worktrees.list.find { |worktree| worktree.branch == branch }
49
+ return unless holder
50
+
51
+ usage_error!('invalid_usage', "Branch #{branch} is already checked out at #{holder.path}")
52
+ end
53
+
54
+ def build_plan(branch, source)
55
+ path = target_path(branch)
56
+ 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
+ end
59
+
60
+ { branch: branch, path: path, source: source.to_s, from: options[:from],
61
+ owner: source == :existing ? nil : owner }
62
+ end
63
+
64
+ def target_path(branch)
65
+ return File.expand_path(options[:path]) if options[:path]
66
+
67
+ config.worktree_path(root: repo.main_root, branch: branch)
68
+ end
69
+
70
+ def owner
71
+ value = options[:owner] || 'agent'
72
+ return value if Ownership::OWNERS.include?(value)
73
+
74
+ usage_error!('invalid_options', "Unknown owner '#{value}' (use agent or human)")
75
+ end
76
+
77
+ def add(worktrees, plan)
78
+ source = plan[:source].to_sym
79
+ result = worktrees.add(plan[:path], branch: plan[:branch], source: source,
80
+ start: plan[:from])
81
+ git_error!('worktree add', result) unless result.success?
82
+
83
+ Ownership.new.mark(plan[:branch], plan[:owner]) if plan[:owner]
84
+ report(plan)
85
+ end
86
+
87
+ def report(plan)
88
+ ui.report(status: 'done', plan: plan, result: outcome(plan),
89
+ text: "Added worktree #{plan[:path]} on #{plan[:branch]}")
90
+ end
91
+
92
+ def outcome(plan)
93
+ head = Git::BashCommand.exec('git', '-C', plan[:path], 'rev-parse', 'HEAD').strip
94
+ plan.slice(:branch, :path, :source, :owner).merge(head: head)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Removes the worktrees whose branch is no longer needed (same criteria as `clean`).
6
+ # Main, current, locked and dirty worktrees are skipped with the reason unless --force
7
+ # allows locked and dirty ones; the main checkout and the current worktree never go.
8
+ # Removal, snapshots and undo work as in `wt remove`. Branches are kept: run `gitflash clean`
9
+ # afterwards to delete them. Worktrees with a missing directory belong to `wt prune`.
10
+ class WorktreeClean < WorktreeRemove
11
+ include CleanCriteria
12
+
13
+ def call
14
+ chosen, @skipped = select
15
+ @reasons = chosen.to_h { |candidate| [candidate.item.path, candidate.reasons] }
16
+ return nothing_to_clean if chosen.empty?
17
+
18
+ remove(chosen.map(&:item))
19
+ end
20
+
21
+ private
22
+
23
+ def select
24
+ Cleanup.worktrees(worktrees.list, branches: repo.branches(merged_status: true),
25
+ rules: rules, protection: Protection.new(config: config),
26
+ blocker: ->(worktree) { blocker(worktree) })
27
+ end
28
+
29
+ def blocker(worktree)
30
+ worktree.removal_blocker(force: force?, current: current?(worktree))
31
+ end
32
+
33
+ def build_plan(chosen)
34
+ super.merge(skipped: @skipped)
35
+ end
36
+
37
+ def plan_row(worktree)
38
+ super.merge(reasons: @reasons[worktree.path])
39
+ end
40
+
41
+ def nothing_to_clean
42
+ plan = { worktrees: [], force: force?, skipped: @skipped }
43
+ text = (['Nothing to clean'] + @skipped.map { |row| "Kept #{row[:path]}: #{row[:reason]}" })
44
+ ui.report(status: 'noop', plan: plan, result: { removed: [], failed: [] },
45
+ text: text.join("\n"))
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Shared lookup for the `worktree` commands that act on one worktree
6
+ class WorktreeCommand < Base
7
+ private
8
+
9
+ def worktrees
10
+ @worktrees ||= Worktrees.new(repo: repo)
11
+ end
12
+
13
+ # The worktree named by a path or a branch; raises `unknown_worktree` when none matches
14
+ def find!(target, list = worktrees.list)
15
+ path = File.expand_path(target)
16
+ list.find { |worktree| worktree.path == path || worktree.branch == target } ||
17
+ usage_error!('unknown_worktree', "Unknown worktree: #{target}")
18
+ end
19
+
20
+ def refuse_main!(worktree, action)
21
+ return unless worktree.main?
22
+
23
+ usage_error!('protected_worktree',
24
+ "#{worktree.path} is the main checkout; cannot #{action}")
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Lists the worktrees of the repository
6
+ class WorktreeList < Base
7
+ def call
8
+ worktrees = Worktrees.new(repo: repo).list
9
+ ui.report(status: 'done', result: { worktrees: worktrees.map(&:to_h) },
10
+ text: table(worktrees))
11
+ end
12
+
13
+ private
14
+
15
+ def table(worktrees)
16
+ width = worktrees.map { |worktree| worktree.path.length }.max
17
+ worktrees.map { |worktree| row(worktree, width) }.join("\n")
18
+ end
19
+
20
+ def row(worktree, width)
21
+ [worktree.path.ljust(width), worktree.branch || "(detached #{worktree.head.to_s[0, 7]})",
22
+ flags(worktree)].join(' ').rstrip
23
+ end
24
+
25
+ def flags(worktree)
26
+ labels = state_labels(worktree) + position_labels(worktree)
27
+ labels.select { |_label, shown| shown }.map(&:first).join(', ')
28
+ end
29
+
30
+ def state_labels(worktree)
31
+ [['main', worktree.main?], ['agent', worktree.owner == 'agent'],
32
+ ['dirty', worktree.dirty?], ['dirty state unknown', unknown_dirty?(worktree)],
33
+ ['locked', worktree.locked?], ['missing', worktree.missing?],
34
+ ['merged', worktree.merged && worktree.branch != repo.default_branch]]
35
+ end
36
+
37
+ # git status failed in a directory that exists; JSON keeps `dirty: null`
38
+ def unknown_dirty?(worktree)
39
+ worktree.dirty.nil? && !worktree.missing? && !worktree.bare
40
+ end
41
+
42
+ def position_labels(worktree)
43
+ [["ahead #{worktree.ahead}", worktree.ahead.to_i.positive?],
44
+ ["behind #{worktree.behind}", worktree.behind.to_i.positive?]]
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Locks or unlocks a worktree, so `prune`, `remove` and `clean` leave it alone.
6
+ # Needs no confirmation: the change is reversed by the opposite command.
7
+ class WorktreeLock < WorktreeCommand
8
+ def call(target = nil)
9
+ unless target
10
+ usage_error!('invalid_usage',
11
+ "Pass the worktree: gitflash wt #{verb} WORKTREE")
12
+ end
13
+ worktree = find!(target)
14
+ refuse_main!(worktree, verb)
15
+ return noop(worktree) if worktree.locked? == lock?
16
+ return planned(plan(worktree), "Would #{verb} #{worktree.path}") if ui.dry_run?
17
+
18
+ change(worktree)
19
+ end
20
+
21
+ private
22
+
23
+ def lock? = true
24
+
25
+ def verb = lock? ? 'lock' : 'unlock'
26
+
27
+ def plan(worktree)
28
+ { path: worktree.path, reason: (options[:reason] if lock?) }.compact
29
+ end
30
+
31
+ def change(worktree)
32
+ result = git_change(worktree)
33
+ git_error!("worktree #{verb}", result) unless result.success?
34
+
35
+ report('done', worktree, "#{verb.capitalize}ed #{worktree.path}")
36
+ end
37
+
38
+ def git_change(worktree)
39
+ return worktrees.unlock(worktree.path) unless lock?
40
+
41
+ worktrees.lock(worktree.path, reason: options[:reason])
42
+ end
43
+
44
+ def noop(worktree)
45
+ report('noop', worktree, "#{worktree.path} is #{lock? ? 'already locked' : 'not locked'}")
46
+ end
47
+
48
+ def report(status, worktree, text)
49
+ ui.report(status: status, plan: plan(worktree), text: text,
50
+ result: plan(worktree).merge(locked: lock?))
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Moves a worktree to another directory. Refuses the main checkout, the current worktree
6
+ # (its directory would disappear under the caller) and locked worktrees unless --force.
7
+ # Needs no confirmation: moving it back reverses it.
8
+ class WorktreeMove < WorktreeCommand
9
+ def call(target = nil, destination = nil)
10
+ unless target && destination
11
+ usage_error!('invalid_usage',
12
+ 'Pass the worktree and its new path')
13
+ end
14
+ worktree = find!(target)
15
+ check!(worktree)
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?
18
+
19
+ move(plan)
20
+ end
21
+
22
+ private
23
+
24
+ def force?
25
+ options[:force] ? true : false
26
+ end
27
+
28
+ def check!(worktree)
29
+ refuse_main!(worktree, 'move')
30
+ if worktree.path == repo.toplevel
31
+ usage_error!('protected_worktree', "#{worktree.path} is the current worktree")
32
+ end
33
+ return unless worktree.locked? && !force?
34
+
35
+ usage_error!('protected_worktree', "#{worktree.path} is locked (use --force)")
36
+ end
37
+
38
+ def move(plan)
39
+ result = worktrees.move(plan[:from], plan[:to], force: plan[:force])
40
+ git_error!('worktree move', result) unless result.success?
41
+
42
+ ui.report(status: 'done', plan: plan, result: plan.slice(:from, :to),
43
+ text: "Moved #{plan[:from]} to #{plan[:to]}")
44
+ end
45
+ end
46
+ end
47
+ end