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,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # The worktrees of the repository. All parsing of `git worktree` output lives here;
5
+ # callers get Worktree records and never see porcelain text.
6
+ class Worktrees
7
+ def initialize(repo:, bash: Git::BashCommand)
8
+ @repo = repo
9
+ @bash = bash
10
+ end
11
+
12
+ # Every worktree, main checkout first, each with its dirty state and branch status
13
+ def list
14
+ branches = @repo.branches(merged_status: true).to_h { |branch| [branch.name, branch] }
15
+ records.each_with_index.map { |record, index| build(record, index.zero?, branches) }
16
+ end
17
+
18
+ # How `add` will get the branch: an existing local branch, a new local branch that tracks
19
+ # origin/<branch>, or a new branch (from HEAD or `start`)
20
+ def source_for(branch)
21
+ return :existing if local_branch?(branch)
22
+
23
+ remote_branch?(branch) ? :remote : :new
24
+ end
25
+
26
+ # Creates the worktree and returns the git Result. `start` only applies to a new branch.
27
+ def add(path, branch:, source:, start: nil)
28
+ args = case source
29
+ when :existing then [path, branch]
30
+ when :remote then ['--track', '-b', branch, '--', path, "origin/#{branch}"]
31
+ else ['-b', branch, '--', path, *start]
32
+ end
33
+ args.unshift('--') if source == :existing
34
+ run('worktree', 'add', *args)
35
+ end
36
+
37
+ # Adds a worktree again for undo: on `branch` (created at `create_at` when it no longer
38
+ # exists) or detached at `detach_at`. A directory git still lists as missing is reused.
39
+ def attach(path, branch: nil, create_at: nil, detach_at: nil)
40
+ force = registered_missing?(path) ? ['--force'] : []
41
+ return run('worktree', 'add', *force, '--detach', '--', path, detach_at) if detach_at
42
+ return run('worktree', 'add', *force, '--', path, branch) if local_branch?(branch)
43
+
44
+ run('worktree', 'add', *force, '-b', branch, '--', path, create_at)
45
+ end
46
+
47
+ # Ignored files and directories (a directory counts once) of a worktree, which snapshots
48
+ # do not save. Empty when the directory is missing.
49
+ def ignored(path)
50
+ stdout, _stderr, success = @bash.capture('git', '-C', path, 'ls-files', '-z', '--others',
51
+ '--ignored', '--exclude-standard', '--directory')
52
+ success ? stdout.split("\0") : []
53
+ end
54
+
55
+ def lock(path, reason: nil)
56
+ run('worktree', 'lock', *(reason ? ['--reason', reason] : []), '--', path)
57
+ end
58
+
59
+ def unlock(path) = run('worktree', 'unlock', '--', path)
60
+
61
+ # A locked worktree needs `force`
62
+ def move(path, destination, force: false)
63
+ run('worktree', 'move', *(force ? %w[--force --force] : []), '--', path, destination)
64
+ end
65
+
66
+ def prune
67
+ run('worktree', 'prune')
68
+ end
69
+
70
+ # Removes a worktree directory. A locked one needs `force`, a dirty one too.
71
+ def remove(path, force: false)
72
+ run('worktree', 'remove', *(force ? %w[--force --force] : []), '--', path)
73
+ end
74
+
75
+ private
76
+
77
+ def registered_missing?(path)
78
+ wanted = normalized_path(path)
79
+ list.any? { |worktree| worktree.missing? && normalized_path(worktree.path) == wanted }
80
+ end
81
+
82
+ # Same directory under any spelling (symlinked parents); the directory itself may not exist
83
+ def normalized_path(path)
84
+ File.join(File.realpath(File.dirname(path)), File.basename(path))
85
+ rescue Errno::ENOENT
86
+ File.expand_path(path)
87
+ end
88
+
89
+ def local_branch?(branch) = ref?("#{Repo::HEADS}#{branch}")
90
+
91
+ def remote_branch?(branch) = ref?("#{Repo::REMOTE_HEAD}#{branch}")
92
+
93
+ def ref?(full_name)
94
+ _stdout, _stderr, success = @bash.capture('git', 'show-ref', '--verify', '--quiet', full_name)
95
+ success
96
+ end
97
+
98
+ def run(*)
99
+ stdout, stderr, success = @bash.capture('git', *)
100
+ Result.new(success: success,
101
+ output: [stdout, stderr].map(&:strip).reject(&:empty?).join("\n"))
102
+ end
103
+
104
+ def records
105
+ output = @bash.exec('git', 'worktree', 'list', '--porcelain', '-z')
106
+ # `-z` ends every attribute with NUL and every record with an extra NUL, so paths with
107
+ # newlines and lock reasons come back as written
108
+ output.split("\0\0").reject(&:empty?).map { |block| parse(block) }
109
+ end
110
+
111
+ def parse(block)
112
+ block.split("\0").reject(&:empty?).to_h do |attribute|
113
+ key, value = attribute.split(' ', 2)
114
+ [key, value || true]
115
+ end
116
+ end
117
+
118
+ def build(record, main, branches)
119
+ branch = record['branch']&.delete_prefix(Repo::HEADS)
120
+ Worktree.new(**state(record, main), branch: branch, **branch_status(branches[branch]))
121
+ end
122
+
123
+ def state(record, main)
124
+ path = record.fetch('worktree')
125
+ missing = record.key?('prunable') || !File.directory?(path)
126
+ bare = record.key?('bare')
127
+ { path: path, head: record['HEAD'], main: main, bare: bare, missing: missing,
128
+ locked: record.key?('locked'), lock_reason: lock_reason(record['locked']),
129
+ dirty: dirty(path, missing, bare) }
130
+ end
131
+
132
+ def branch_status(info)
133
+ { ahead: info&.ahead, behind: info&.behind, merged: info&.merged, owner: info&.owner }
134
+ end
135
+
136
+ def lock_reason(value)
137
+ value.is_a?(String) && !value.empty? ? value : nil
138
+ end
139
+
140
+ def dirty(path, missing, bare)
141
+ return nil if missing || bare
142
+
143
+ stdout, _stderr, success = @bash.capture('git', '-C', path, 'status', '--porcelain')
144
+ success ? !stdout.strip.empty? : nil
145
+ end
146
+ end
147
+ end