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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +26 -2
- data/command_descriptions.yml +76 -0
- data/lib/gitflash/branch.rb +7 -2
- data/lib/gitflash/cherry_pick.rb +128 -0
- data/lib/gitflash/cleanup.rb +70 -0
- data/lib/gitflash/cli.rb +37 -0
- data/lib/gitflash/commands/base.rb +9 -3
- data/lib/gitflash/commands/branches.rb +1 -0
- data/lib/gitflash/commands/clean.rb +51 -0
- data/lib/gitflash/commands/clean_criteria.rb +29 -0
- data/lib/gitflash/commands/delete.rb +17 -5
- data/lib/gitflash/commands/hook_install.rb +17 -9
- data/lib/gitflash/commands/hook_status.rb +24 -6
- data/lib/gitflash/commands/mark.rb +85 -0
- data/lib/gitflash/commands/pick.rb +131 -0
- data/lib/gitflash/commands/pick_control.rb +67 -0
- data/lib/gitflash/commands/pick_outcome.rb +52 -0
- data/lib/gitflash/commands/removal_report.rb +65 -0
- data/lib/gitflash/commands/undo.rb +38 -6
- data/lib/gitflash/commands/worktree_add.rb +98 -0
- data/lib/gitflash/commands/worktree_clean.rb +49 -0
- data/lib/gitflash/commands/worktree_command.rb +28 -0
- data/lib/gitflash/commands/worktree_list.rb +48 -0
- data/lib/gitflash/commands/worktree_lock.rb +54 -0
- data/lib/gitflash/commands/worktree_move.rb +47 -0
- data/lib/gitflash/commands/worktree_prune.rb +41 -0
- data/lib/gitflash/commands/worktree_remove.rb +119 -0
- data/lib/gitflash/commands/worktree_unlock.rb +12 -0
- data/lib/gitflash/config.rb +95 -0
- data/lib/gitflash/config_keys.rb +19 -0
- data/lib/gitflash/git/in_directory.rb +29 -0
- data/lib/gitflash/hook/branch_marker.rb +52 -0
- data/lib/gitflash/hook/claude.rb +23 -3
- data/lib/gitflash/hook/command_parser.rb +29 -9
- data/lib/gitflash/hook/creations.rb +85 -0
- data/lib/gitflash/hook/settings.rb +18 -5
- data/lib/gitflash/hook_cli.rb +12 -4
- data/lib/gitflash/ownership.rb +59 -0
- data/lib/gitflash/protection.rb +19 -0
- data/lib/gitflash/repo.rb +2 -3
- data/lib/gitflash/restore.rb +8 -7
- data/lib/gitflash/state_capture.rb +2 -2
- data/lib/gitflash/version.rb +1 -1
- data/lib/gitflash/worktree.rb +32 -0
- data/lib/gitflash/worktree_cli.rb +115 -0
- data/lib/gitflash/worktree_revival.rb +55 -0
- data/lib/gitflash/worktrees.rb +147 -0
- data/schema/v1.json +1050 -2
- metadata +30 -1
|
@@ -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
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Commands
|
|
5
|
+
# Forgets worktrees whose directory is gone (locked ones stay). Branches are not touched.
|
|
6
|
+
# Takes no snapshot: only git's own bookkeeping for the missing directory is removed.
|
|
7
|
+
class WorktreePrune < WorktreeCommand
|
|
8
|
+
def call
|
|
9
|
+
stale = worktrees.list.select { |worktree| worktree.missing? && !worktree.locked? }
|
|
10
|
+
return noop if stale.empty?
|
|
11
|
+
|
|
12
|
+
prune_or_ask({ worktrees: stale.map(&:path) })
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def prune_or_ask(plan)
|
|
18
|
+
return planned(plan, "Would prune:\n#{bullets(plan)}") if ui.dry_run?
|
|
19
|
+
|
|
20
|
+
question = "You are about to prune:\n\n#{bullets(plan)}"
|
|
21
|
+
ui.confirm?(question, plan: plan) ? prune(plan) : cancelled(plan)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def prune(plan)
|
|
25
|
+
result = worktrees.prune
|
|
26
|
+
git_error!('worktree prune', result) unless result.success?
|
|
27
|
+
|
|
28
|
+
ui.report(status: 'done', plan: plan, result: { pruned: plan[:worktrees] },
|
|
29
|
+
text: "Pruned:\n#{bullets(plan)}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def noop
|
|
33
|
+
ui.report(status: 'noop', result: { pruned: [] }, text: 'No missing worktrees to prune')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def bullets(plan)
|
|
37
|
+
plan[:worktrees].map { |path| "* #{path}" }.join("\n")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Commands
|
|
5
|
+
# Removes worktrees given as paths or branch names, or picked from a menu.
|
|
6
|
+
# The main checkout, the worktree gitflash runs in, locked and dirty worktrees are refused
|
|
7
|
+
# unless --force is given (the main checkout and the current one never can be removed).
|
|
8
|
+
# Each removal saves a snapshot of the worktree first; `gitflash undo ID` brings the
|
|
9
|
+
# directory, its branch and its uncommitted files back.
|
|
10
|
+
class WorktreeRemove < WorktreeCommand
|
|
11
|
+
include RemovalReport
|
|
12
|
+
|
|
13
|
+
def call(*targets)
|
|
14
|
+
worktrees = Worktrees.new(repo: repo).list
|
|
15
|
+
targets = pick(worktrees) if targets.empty?
|
|
16
|
+
return 0 if targets.nil?
|
|
17
|
+
|
|
18
|
+
chosen = resolve(targets, worktrees)
|
|
19
|
+
remove(chosen)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def pick(worktrees)
|
|
25
|
+
require_interactive!('Pass the worktrees to remove: gitflash wt remove PATH_OR_BRANCH...')
|
|
26
|
+
choices = worktrees.reject { |worktree| worktree.main? || current?(worktree) }
|
|
27
|
+
.to_h { |wt| ["#{wt.branch || '(detached)'} #{wt.path}", wt.path] }
|
|
28
|
+
return report_nothing('No worktrees available to remove') if choices.empty?
|
|
29
|
+
|
|
30
|
+
picked = ui.multi_select('Select worktrees to remove', choices)
|
|
31
|
+
picked.empty? ? report_nothing('No worktrees selected') : picked
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def force?
|
|
35
|
+
options[:force] ? true : false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def current?(worktree)
|
|
39
|
+
worktree.path == repo.toplevel
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolve(targets, worktrees)
|
|
43
|
+
chosen = targets.uniq.map { |target| find!(target, worktrees) }.uniq(&:path)
|
|
44
|
+
refused = chosen.filter_map do |worktree|
|
|
45
|
+
worktree.removal_blocker(force: force?, current: current?(worktree))
|
|
46
|
+
end
|
|
47
|
+
usage_error!('protected_worktree', refused.join('; ')) if refused.any?
|
|
48
|
+
chosen
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def remove(chosen)
|
|
52
|
+
plan = build_plan(chosen)
|
|
53
|
+
return planned(plan, "Would remove:\n#{preview(chosen, plan)}") if dry_run?
|
|
54
|
+
return cancelled(plan) unless ui.confirm?(summary(chosen, plan), plan: plan)
|
|
55
|
+
|
|
56
|
+
outcomes = chosen.map { |worktree| remove_one(worktree) }
|
|
57
|
+
report_results(plan, outcomes)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def dry_run? = ui.dry_run?
|
|
61
|
+
|
|
62
|
+
def preview(chosen, plan) = "#{bullets(chosen)}#{ignored_warning(plan)}"
|
|
63
|
+
|
|
64
|
+
# The plan reported and returned in JSON; `wt clean` adds why each worktree was chosen
|
|
65
|
+
def build_plan(chosen)
|
|
66
|
+
{ worktrees: chosen.map { |worktree| plan_row(worktree) }, force: force? }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def plan_row(worktree)
|
|
70
|
+
ignored = worktree.missing? ? [] : Worktrees.new(repo: repo).ignored(worktree.path)
|
|
71
|
+
{ path: worktree.path, branch: worktree.branch, dirty: worktree.dirty?,
|
|
72
|
+
locked: worktree.locked?, ignored_count: ignored.size, ignored: ignored.first(5) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def remove_one(worktree)
|
|
76
|
+
snapshot = snapshot_for(worktree)
|
|
77
|
+
unsaved = snapshot.skipped_files
|
|
78
|
+
return refuse_unsaved(worktree, unsaved) if unsaved.any?
|
|
79
|
+
|
|
80
|
+
result = Worktrees.new(repo: repo).remove(worktree.path, force: force?)
|
|
81
|
+
Removal.new(worktree: worktree, result: result, snapshot: (snapshot if result.success?))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Files a snapshot cannot hold (untracked and over 50 MB) would be lost for good
|
|
85
|
+
def refuse_unsaved(worktree, files)
|
|
86
|
+
shown = files.first(3).join(', ')
|
|
87
|
+
shown += ", and #{files.size - 3} more" if files.size > 3
|
|
88
|
+
message = "#{files.size} untracked file(s) over 50 MB cannot be saved by a snapshot " \
|
|
89
|
+
"(#{shown}); move or delete them first"
|
|
90
|
+
Removal.new(worktree: worktree, result: Result.new(success: false, output: message),
|
|
91
|
+
snapshot: nil)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A missing directory has no HEAD or files to save; only the branch tip is
|
|
95
|
+
def snapshot_for(worktree)
|
|
96
|
+
reason = "gitflash wt remove #{worktree.path}"
|
|
97
|
+
branches = [worktree.branch].compact
|
|
98
|
+
return take_snapshot(reason, scope: %w[branches], branches: branches) if worktree.missing?
|
|
99
|
+
|
|
100
|
+
take_snapshot(reason, scope: %w[branches head worktree], branches: branches,
|
|
101
|
+
dir: worktree.path)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def summary(chosen, plan)
|
|
105
|
+
"You are about to remove the following worktrees:\n\n#{bullets(chosen)}" \
|
|
106
|
+
"#{ignored_warning(plan)}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def bullets(chosen)
|
|
110
|
+
chosen.map { |worktree| "* #{worktree.path} (#{worktree.branch || 'detached'})" }.join("\n")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def report_nothing(text)
|
|
114
|
+
ui.report(status: 'noop', result: { removed: [], failed: [] }, text: text)
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
# Settings from `.gitflash.yml` in the main checkout and `~/.config/gitflash.yml`.
|
|
7
|
+
# Precedence: repository file, then user file, then built-in defaults.
|
|
8
|
+
# Command-line flags are applied by the commands themselves and win over all of these.
|
|
9
|
+
# An unknown key or a value of the wrong type raises a UsageError, so a typo never goes unnoticed.
|
|
10
|
+
Config = Data.define(:protected_patterns, :stale_days, :worktree_dir) do
|
|
11
|
+
# `root` is the main checkout; `home` is where the user file lives
|
|
12
|
+
def self.load(root:, home: Dir.home)
|
|
13
|
+
values = [File.join(home, ConfigKeys::USER_FILE), File.join(root, ConfigKeys::REPO_FILE)]
|
|
14
|
+
.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'])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.defaults
|
|
21
|
+
new(protected_patterns: [], stale_days: 30, worktree_dir: ConfigKeys::DEFAULT_WORKTREE_DIR)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.read(path)
|
|
25
|
+
return {} unless File.file?(path)
|
|
26
|
+
|
|
27
|
+
reject_duplicate_keys!(path)
|
|
28
|
+
data = YAML.safe_load_file(path) || {}
|
|
29
|
+
validate!(data, path)
|
|
30
|
+
data
|
|
31
|
+
rescue Psych::Exception, SystemCallError, IOError => e
|
|
32
|
+
raise invalid(path, "cannot be read (#{e.message.lines.first.strip})")
|
|
33
|
+
end
|
|
34
|
+
private_class_method :read
|
|
35
|
+
|
|
36
|
+
# A YAML loader keeps only the last of two equal keys, which could drop a `protected` list
|
|
37
|
+
def self.reject_duplicate_keys!(path)
|
|
38
|
+
root = Psych.parse_file(path)
|
|
39
|
+
return unless root
|
|
40
|
+
|
|
41
|
+
duplicate = duplicate_key(root)
|
|
42
|
+
raise invalid(path, "has the key '#{duplicate}' more than once") if duplicate
|
|
43
|
+
end
|
|
44
|
+
private_class_method :reject_duplicate_keys!
|
|
45
|
+
|
|
46
|
+
def self.duplicate_key(node)
|
|
47
|
+
own = node.is_a?(Psych::Nodes::Mapping) ? own_duplicate(node) : nil
|
|
48
|
+
own || (node.children || []).filter_map { |child| duplicate_key(child) }.first
|
|
49
|
+
end
|
|
50
|
+
private_class_method :duplicate_key
|
|
51
|
+
|
|
52
|
+
def self.own_duplicate(mapping)
|
|
53
|
+
keys = mapping.children.each_slice(2).filter_map do |key, _value|
|
|
54
|
+
key.value if key.respond_to?(:value)
|
|
55
|
+
end
|
|
56
|
+
keys.tally.find { |_key, count| count > 1 }&.first
|
|
57
|
+
end
|
|
58
|
+
private_class_method :own_duplicate
|
|
59
|
+
|
|
60
|
+
def self.validate!(data, path)
|
|
61
|
+
raise invalid(path, 'must be a mapping') unless data.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
unknown = data.keys - ConfigKeys::DEFAULTS.keys
|
|
64
|
+
raise invalid(path, "has unknown keys: #{unknown.join(', ')}") if unknown.any?
|
|
65
|
+
|
|
66
|
+
check_types!(data, path)
|
|
67
|
+
end
|
|
68
|
+
private_class_method :validate!
|
|
69
|
+
|
|
70
|
+
def self.check_types!(data, path)
|
|
71
|
+
data.each do |key, value|
|
|
72
|
+
check, expected = ConfigKeys::CHECKS.fetch(key)
|
|
73
|
+
raise invalid(path, "'#{key}' must be #{expected}") unless check.call(value)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
private_class_method :check_types!
|
|
77
|
+
|
|
78
|
+
def self.invalid(path, problem)
|
|
79
|
+
UsageError.new("#{path} #{problem}", code: 'invalid_config')
|
|
80
|
+
end
|
|
81
|
+
private_class_method :invalid
|
|
82
|
+
|
|
83
|
+
# True when the branch name matches a configured pattern (shell-style `*` and `?`)
|
|
84
|
+
def protected?(name)
|
|
85
|
+
protected_patterns.any? { |pattern| File.fnmatch?(pattern, name) }
|
|
86
|
+
end
|
|
87
|
+
|
|
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
|
+
def worktree_path(root:, branch:)
|
|
91
|
+
path = format(worktree_dir, repo: File.basename(root), branch: branch)
|
|
92
|
+
File.expand_path(path, root)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
# Built-in defaults, files and the check each key must pass
|
|
5
|
+
module ConfigKeys
|
|
6
|
+
REPO_FILE = '.gitflash.yml'
|
|
7
|
+
USER_FILE = File.join('.config', 'gitflash.yml')
|
|
8
|
+
DEFAULT_WORKTREE_DIR = '../%<repo>s.worktrees/%<branch>s'
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
'protected' => [], 'stale_days' => 30, 'worktree_dir' => DEFAULT_WORKTREE_DIR
|
|
11
|
+
}.freeze
|
|
12
|
+
CHECKS = {
|
|
13
|
+
'protected' => [->(v) { v.is_a?(Array) && v.all?(String) },
|
|
14
|
+
'a list of branch name patterns'],
|
|
15
|
+
'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']
|
|
17
|
+
}.freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Git
|
|
5
|
+
# Runs every git command inside another directory (`git -C DIR ...`). It has the same
|
|
6
|
+
# interface as BashCommand, so StateCapture, Snapshots and Restore can work on a linked
|
|
7
|
+
# worktree without knowing about it.
|
|
8
|
+
class InDirectory
|
|
9
|
+
def initialize(path, bash: BashCommand)
|
|
10
|
+
@path = path
|
|
11
|
+
@bash = bash
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def exec(*args, env: {})
|
|
15
|
+
@bash.exec(*scoped(args), env: env)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def capture(*args, env: {})
|
|
19
|
+
@bash.capture(*scoped(args), env: env)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def scoped(args)
|
|
25
|
+
args.first == 'git' ? ['git', '-C', @path, *args.drop(1)] : args
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|