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,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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Unlocks a worktree; see WorktreeLock
6
+ class WorktreeUnlock < WorktreeLock
7
+ private
8
+
9
+ def lock? = false
10
+ end
11
+ end
12
+ 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