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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +69 -2
- data/bin/gitflash +10 -0
- data/command_descriptions.yml +99 -0
- data/lib/gitflash/branch.rb +14 -2
- data/lib/gitflash/cherry_pick.rb +128 -0
- data/lib/gitflash/cleanup.rb +70 -0
- data/lib/gitflash/cli.rb +72 -21
- data/lib/gitflash/command_runner.rb +23 -0
- data/lib/gitflash/commands/base.rb +15 -0
- data/lib/gitflash/commands/branches.rb +1 -0
- data/lib/gitflash/commands/checkout.rb +2 -1
- data/lib/gitflash/commands/clean.rb +51 -0
- data/lib/gitflash/commands/clean_criteria.rb +29 -0
- data/lib/gitflash/commands/delete.rb +23 -8
- data/lib/gitflash/commands/gc.rb +41 -0
- data/lib/gitflash/commands/hook_install.rb +77 -0
- data/lib/gitflash/commands/hook_status.rb +80 -0
- 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/reset.rb +7 -1
- data/lib/gitflash/commands/snapshot_create.rb +30 -0
- data/lib/gitflash/commands/snapshot_list.rb +20 -0
- data/lib/gitflash/commands/undo.rb +131 -0
- 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/bash_command.rb +5 -4
- data/lib/gitflash/git/in_directory.rb +29 -0
- data/lib/gitflash/hook/branch_marker.rb +52 -0
- data/lib/gitflash/hook/claude.rb +126 -0
- data/lib/gitflash/hook/command_parser.rb +121 -0
- data/lib/gitflash/hook/creations.rb +85 -0
- data/lib/gitflash/hook/rules.rb +108 -0
- data/lib/gitflash/hook/settings.rb +73 -0
- data/lib/gitflash/hook_cli.rb +77 -0
- data/lib/gitflash/ownership.rb +59 -0
- data/lib/gitflash/protection.rb +19 -0
- data/lib/gitflash/repo.rb +12 -3
- data/lib/gitflash/restore.rb +141 -0
- data/lib/gitflash/snapshot.rb +47 -0
- data/lib/gitflash/snapshots.rb +70 -0
- data/lib/gitflash/state_capture.rb +129 -0
- data/lib/gitflash/ui.rb +7 -5
- 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 +1885 -102
- 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
|