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,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
|