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,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module Gitflash
|
|
8
|
+
# Creates, lists and deletes snapshots.
|
|
9
|
+
#
|
|
10
|
+
# Each snapshot is one commit under refs/gitflash/snapshots/<id>. Its message is the snapshot
|
|
11
|
+
# metadata as JSON, its tree is empty, and its parents are every commit the snapshot refers
|
|
12
|
+
# to, so git keeps them all as long as the ref exists. Nothing outside git's own object store
|
|
13
|
+
# is written, and linked worktrees share the snapshots.
|
|
14
|
+
class Snapshots
|
|
15
|
+
REF_PREFIX = 'refs/gitflash/snapshots/'
|
|
16
|
+
SCOPES = %w[branches head worktree stashes].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(bash: Git::BashCommand, clock: -> { Time.now })
|
|
19
|
+
@bash = bash
|
|
20
|
+
@clock = clock
|
|
21
|
+
@capture = StateCapture.new(bash: bash)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Saves the current state. `branches` limits the recorded branches (nil records all).
|
|
25
|
+
# Returns the latest snapshot instead when nothing changed since it was taken.
|
|
26
|
+
def create(reason:, scope: SCOPES, branches: nil)
|
|
27
|
+
state = @capture.call(scope, branches)
|
|
28
|
+
latest = list.first
|
|
29
|
+
return latest if latest&.state == state
|
|
30
|
+
|
|
31
|
+
store(Snapshot.new(**identity, reason: reason, **state))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Snapshots, newest first
|
|
35
|
+
def list
|
|
36
|
+
git('for-each-ref', '--sort=-refname', '--format=%(contents:subject)', REF_PREFIX)
|
|
37
|
+
.each_line(chomp: true).reject(&:empty?)
|
|
38
|
+
.map { |line| Snapshot.from_h(JSON.parse(line)) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def find(id)
|
|
42
|
+
list.find { |snapshot| snapshot.id == id }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def delete(id)
|
|
46
|
+
git('update-ref', '-d', "#{REF_PREFIX}#{id}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def git(*)
|
|
52
|
+
@bash.exec('git', *)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Ids sort by time down to the microsecond, so several snapshots within one second still
|
|
56
|
+
# list in the order they were taken; the random suffix keeps them unique.
|
|
57
|
+
def identity
|
|
58
|
+
now = @clock.call.utc
|
|
59
|
+
id = "#{now.strftime('%Y%m%dT%H%M%S%6N')}-#{SecureRandom.hex(2)}"
|
|
60
|
+
{ id: id, created_at: now.iso8601(6) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def store(snapshot)
|
|
64
|
+
meta = @capture.commit_tree(@capture.empty_tree, parents: snapshot.object_ids,
|
|
65
|
+
message: JSON.generate(snapshot.to_h))
|
|
66
|
+
git('update-ref', "#{REF_PREFIX}#{snapshot.id}", meta)
|
|
67
|
+
snapshot
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
|
|
6
|
+
module Gitflash
|
|
7
|
+
# Reads the repository state a snapshot saves and writes the needed git objects.
|
|
8
|
+
# Never changes branches, the index or the working tree.
|
|
9
|
+
class StateCapture
|
|
10
|
+
MAX_UNTRACKED_FILE_BYTES = 50 * 1024 * 1024
|
|
11
|
+
ADD_BATCH_SIZE = 500
|
|
12
|
+
IDENTITY = {
|
|
13
|
+
'GIT_AUTHOR_NAME' => 'gitflash', 'GIT_AUTHOR_EMAIL' => 'gitflash@localhost',
|
|
14
|
+
'GIT_COMMITTER_NAME' => 'gitflash', 'GIT_COMMITTER_EMAIL' => 'gitflash@localhost'
|
|
15
|
+
}.freeze
|
|
16
|
+
# Content commits get a fixed date so the same content always gives the same sha
|
|
17
|
+
FIXED_DATE = {
|
|
18
|
+
'GIT_AUTHOR_DATE' => '1970-01-01T00:00:00Z', 'GIT_COMMITTER_DATE' => '1970-01-01T00:00:00Z'
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def initialize(bash: Git::BashCommand)
|
|
22
|
+
@bash = bash
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Snapshot fields except id, time and reason. `branch_names` nil records every branch.
|
|
26
|
+
def call(scope, branch_names)
|
|
27
|
+
head_sha = rev('HEAD')
|
|
28
|
+
state = {
|
|
29
|
+
scope: Snapshots::SCOPES & scope, worktree_path: git('rev-parse', '--show-toplevel'),
|
|
30
|
+
head: { branch: current_branch, sha: head_sha },
|
|
31
|
+
branches: scope.include?('branches') ? branch_tips(branch_names) : {},
|
|
32
|
+
stashes: scope.include?('stashes') ? try('stash', 'list', '--format=%H').to_s.split : [],
|
|
33
|
+
index: nil, worktree: nil, skipped_files: []
|
|
34
|
+
}
|
|
35
|
+
state.merge!(worktree(head_sha)) if scope.include?('worktree') && dirty?
|
|
36
|
+
state
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def commit_tree(tree, parents:, message:, env: {})
|
|
40
|
+
parent_args = parents.uniq.flat_map { |sha| ['-p', sha] }
|
|
41
|
+
git('commit-tree', tree, *parent_args, '-m', message, env: IDENTITY.merge(env))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def empty_tree
|
|
45
|
+
@empty_tree ||= git('mktree')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def git(*, env: {})
|
|
51
|
+
@bash.exec('git', *, env: env).strip
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Stripped stdout, or nil when the command fails
|
|
55
|
+
def try(*)
|
|
56
|
+
stdout, _stderr, success = @bash.capture('git', *)
|
|
57
|
+
success ? stdout.strip : nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def rev(ref)
|
|
61
|
+
try('rev-parse', '--verify', '--quiet', "#{ref}^{commit}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def current_branch
|
|
65
|
+
ref = try('symbolic-ref', '--quiet', 'HEAD')
|
|
66
|
+
ref&.delete_prefix(Repo::HEADS)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def branch_tips(names)
|
|
70
|
+
tips = Branch.parse_tips(git('for-each-ref', "--format=#{Branch::TIPS_FORMAT}", Repo::HEADS))
|
|
71
|
+
names.nil? ? tips : tips.slice(*names)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def dirty?
|
|
75
|
+
!git('status', '--porcelain', '--untracked-files=all').empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def worktree(head_sha)
|
|
79
|
+
parents = [head_sha].compact
|
|
80
|
+
index_tree = try('write-tree') # nil while a merge conflict is unresolved
|
|
81
|
+
tree, skipped = worktree_tree
|
|
82
|
+
{
|
|
83
|
+
index: index_tree && content_commit(index_tree, parents, 'gitflash index'),
|
|
84
|
+
worktree: content_commit(tree, parents, 'gitflash worktree'),
|
|
85
|
+
skipped_files: skipped
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def content_commit(tree, parents, message)
|
|
90
|
+
commit_tree(tree, parents: parents, message: message, env: FIXED_DATE)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Tree of the working tree, built in a temporary copy of the index so the real one is untouched
|
|
94
|
+
def worktree_tree
|
|
95
|
+
top = git('rev-parse', '--show-toplevel')
|
|
96
|
+
Tempfile.create('gitflash-index', git('rev-parse', '--absolute-git-dir')) do |file|
|
|
97
|
+
file.close
|
|
98
|
+
real_index = git('rev-parse', '--path-format=absolute', '--git-path', 'index')
|
|
99
|
+
FileUtils.cp(real_index, file.path) if File.exist?(real_index)
|
|
100
|
+
env = { 'GIT_INDEX_FILE' => file.path }
|
|
101
|
+
|
|
102
|
+
git('-C', top, 'add', '--update', env: env)
|
|
103
|
+
skipped = add_untracked(top, env)
|
|
104
|
+
[git('write-tree', env: env), skipped]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# [path, size] pairs. A file removed since `ls-files` ran (a build writing temporary files)
|
|
109
|
+
# is left out, so `git add` is not given a path that is gone.
|
|
110
|
+
def file_sizes(top, paths)
|
|
111
|
+
paths.filter_map do |path|
|
|
112
|
+
[path, File.lstat(File.join(top, path)).size]
|
|
113
|
+
rescue Errno::ENOENT
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Adds untracked files that are not ignored; returns the ones skipped for their size
|
|
119
|
+
def add_untracked(top, env)
|
|
120
|
+
paths = git('-C', top, 'ls-files', '-z', '--others', '--exclude-standard')
|
|
121
|
+
.split("\0").reject { |path| path.end_with?('/') }
|
|
122
|
+
sizes = file_sizes(top, paths)
|
|
123
|
+
small, large = sizes.partition { |_path, size| size <= MAX_UNTRACKED_FILE_BYTES }
|
|
124
|
+
.map { |group| group.map(&:first) }
|
|
125
|
+
small.each_slice(ADD_BATCH_SIZE) { |batch| git('-C', top, 'add', '--', *batch, env: env) }
|
|
126
|
+
large
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
data/lib/gitflash/ui.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Gitflash
|
|
|
7
7
|
# Menus and confirmations are only shown in a terminal and never in JSON mode.
|
|
8
8
|
#
|
|
9
9
|
# JSON output always uses the same envelope (see schema/v1.json):
|
|
10
|
-
# { schema, command, ok, status, dry_run, plan?, result?, error? }
|
|
10
|
+
# { schema, command, ok, status, dry_run, plan?, result?, undo?, error? }
|
|
11
11
|
class Ui
|
|
12
12
|
JSON_SCHEMA_VERSION = 1
|
|
13
13
|
OK_STATUSES = %w[done planned noop cancelled].freeze
|
|
@@ -33,12 +33,13 @@ module Gitflash
|
|
|
33
33
|
|
|
34
34
|
# Reports the outcome of a command and returns its exit status.
|
|
35
35
|
# `status` is one of done, planned, noop, cancelled or failed.
|
|
36
|
-
|
|
36
|
+
# `fields` may hold plan, result, error and undo (the snapshot that reverts the change).
|
|
37
|
+
def report(status:, text:, **fields)
|
|
37
38
|
if json?
|
|
38
|
-
|
|
39
|
-
$stdout.puts JSON.generate(document)
|
|
39
|
+
$stdout.puts JSON.generate(envelope(status: status, **fields))
|
|
40
40
|
else
|
|
41
41
|
$stdout.puts TerminalText.safe(text)
|
|
42
|
+
$stdout.puts "Undo with: gitflash undo #{fields[:undo].id}" if fields[:undo]
|
|
42
43
|
end
|
|
43
44
|
OK_STATUSES.include?(status) ? 0 : 1
|
|
44
45
|
end
|
|
@@ -73,7 +74,7 @@ module Gitflash
|
|
|
73
74
|
|
|
74
75
|
private
|
|
75
76
|
|
|
76
|
-
def envelope(status:, plan: nil, result: nil, error: nil)
|
|
77
|
+
def envelope(status:, plan: nil, result: nil, error: nil, undo: nil)
|
|
77
78
|
{
|
|
78
79
|
schema: JSON_SCHEMA_VERSION,
|
|
79
80
|
command: @command,
|
|
@@ -82,6 +83,7 @@ module Gitflash
|
|
|
82
83
|
dry_run: dry_run?,
|
|
83
84
|
plan: plan,
|
|
84
85
|
result: result,
|
|
86
|
+
undo: undo && { snapshot: undo.id, command: "gitflash undo #{undo.id}" },
|
|
85
87
|
error: error
|
|
86
88
|
}.compact
|
|
87
89
|
end
|
data/lib/gitflash/version.rb
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
# A checkout of the repository as reported by `git worktree list --porcelain`.
|
|
5
|
+
# `branch` is nil for a detached HEAD. `missing` means the directory is gone (git calls it
|
|
6
|
+
# prunable). `dirty` is nil when it cannot be known (missing directory or bare repository).
|
|
7
|
+
# ahead, behind, merged and owner are those of the branch, nil without a branch.
|
|
8
|
+
Worktree = Data.define(
|
|
9
|
+
:path, :head, :branch, :main, :bare, :locked, :lock_reason, :missing,
|
|
10
|
+
:dirty, :ahead, :behind, :merged, :owner
|
|
11
|
+
) do
|
|
12
|
+
alias_method :main?, :main
|
|
13
|
+
alias_method :locked?, :locked
|
|
14
|
+
alias_method :missing?, :missing
|
|
15
|
+
alias_method :dirty?, :dirty
|
|
16
|
+
|
|
17
|
+
# Why this worktree must not be removed, nil when it can go. `current` is true for the
|
|
18
|
+
# worktree gitflash runs in. The main checkout and the current one are never removable.
|
|
19
|
+
def removal_blocker(force:, current:)
|
|
20
|
+
return "#{path} is the main checkout" if main?
|
|
21
|
+
return "#{path} is the current worktree" if current
|
|
22
|
+
|
|
23
|
+
in_use_blocker unless force || (missing? && !locked?)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def in_use_blocker
|
|
27
|
+
return "#{path} is locked (use --force)" if locked?
|
|
28
|
+
|
|
29
|
+
"#{path} has uncommitted changes (use --force)" if dirty? != false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
# `gitflash worktree ...` (alias `wt`): worktrees for parallel agent work
|
|
7
|
+
class WorktreeCli < Thor
|
|
8
|
+
extend Configuration::Descriptions
|
|
9
|
+
include CommandRunner
|
|
10
|
+
|
|
11
|
+
namespace 'worktree'
|
|
12
|
+
|
|
13
|
+
def self.exit_on_failure?
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc 'list', 'List worktrees with branch, dirty and merge state'
|
|
18
|
+
long_desc <<~TEXT
|
|
19
|
+
Lists every worktree of the repository: absolute path, branch, HEAD, whether it has
|
|
20
|
+
uncommitted changes, ahead/behind counts, whether its branch is merged into the default
|
|
21
|
+
branch, lock state and whether its directory is missing.
|
|
22
|
+
|
|
23
|
+
Agents use the absolute paths from `--json`; no directory switching is needed.
|
|
24
|
+
|
|
25
|
+
Accepts the global --json option after the subcommand: gitflash wt list --json
|
|
26
|
+
TEXT
|
|
27
|
+
def list
|
|
28
|
+
run_command('worktree list', Commands::WorktreeList)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
desc 'add BRANCH', 'Add a worktree for a branch (existing, remote or new)'
|
|
32
|
+
long_desc <<~TEXT
|
|
33
|
+
Adds a worktree for BRANCH. An existing local branch is checked out; a branch that exists
|
|
34
|
+
only on origin is created to track it; any other name creates a new branch from HEAD
|
|
35
|
+
(or from --from). New branches are marked as agent work so `gitflash clean` can find them.
|
|
36
|
+
|
|
37
|
+
The directory defaults to ../<repo>.worktrees/<branch> (setting `worktree_dir` in
|
|
38
|
+
.gitflash.yml changes it). The result lists the absolute path: use it, no directory
|
|
39
|
+
switching is needed.
|
|
40
|
+
|
|
41
|
+
Needs no confirmation. Accepts the global --json and --dry-run options after the
|
|
42
|
+
subcommand: gitflash wt add feature --json
|
|
43
|
+
TEXT
|
|
44
|
+
option :path, type: :string, banner: 'DIR', desc: 'Directory for the worktree'
|
|
45
|
+
option :from, type: :string, banner: 'REF', desc: 'Start a new branch from REF (default HEAD)'
|
|
46
|
+
option :owner, type: :string, enum: Ownership::OWNERS,
|
|
47
|
+
desc: 'Mark a new branch as agent (default) or human work'
|
|
48
|
+
def add(branch = nil)
|
|
49
|
+
run_command('worktree add', Commands::WorktreeAdd, *[branch].compact)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
desc 'lock WORKTREE', 'Lock a worktree so prune, remove and clean leave it alone'
|
|
53
|
+
long_desc 'Locks the worktree given as a path or branch. Add --reason to say why.'
|
|
54
|
+
option :reason, type: :string, desc: 'Why the worktree is locked'
|
|
55
|
+
def lock(worktree = nil)
|
|
56
|
+
run_command('worktree lock', Commands::WorktreeLock, *[worktree].compact)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
desc 'unlock WORKTREE', 'Unlock a worktree'
|
|
60
|
+
def unlock(worktree = nil)
|
|
61
|
+
run_command('worktree unlock', Commands::WorktreeUnlock, *[worktree].compact)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
desc 'move WORKTREE PATH', 'Move a worktree to another directory'
|
|
65
|
+
long_desc <<~TEXT
|
|
66
|
+
Moves the worktree (a path or branch) to PATH. The main checkout and the current worktree
|
|
67
|
+
cannot be moved; a locked worktree needs --force.
|
|
68
|
+
TEXT
|
|
69
|
+
option :force, type: :boolean, default: false, desc: 'Also move a locked worktree'
|
|
70
|
+
def move(worktree = nil, destination = nil)
|
|
71
|
+
run_command('worktree move', Commands::WorktreeMove, *[worktree, destination].compact)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
desc 'prune', 'Forget worktrees whose directory is gone'
|
|
75
|
+
long_desc <<~TEXT
|
|
76
|
+
Removes git's record of worktrees whose directory no longer exists. Locked worktrees stay.
|
|
77
|
+
Branches are not touched. Asks for confirmation; pass --yes to skip it, or --dry-run.
|
|
78
|
+
TEXT
|
|
79
|
+
def prune
|
|
80
|
+
run_command('worktree prune', Commands::WorktreePrune)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
desc 'clean', 'Remove worktrees whose branch is merged, gone, stale or agent-created'
|
|
84
|
+
long_desc <<~TEXT
|
|
85
|
+
Removes worktrees whose branch matches the criteria of `gitflash clean`: merged into the
|
|
86
|
+
default branch or upstream gone by default, plus --stale [DAYS] and --agent. The main
|
|
87
|
+
checkout, the current worktree and worktrees on protected branches are never removed;
|
|
88
|
+
locked worktrees and worktrees with uncommitted changes are skipped (and listed with the
|
|
89
|
+
reason) unless --force is given.
|
|
90
|
+
|
|
91
|
+
Each removal saves a snapshot first, as `wt remove` does. Branches are kept: run
|
|
92
|
+
`gitflash clean` afterwards to delete them. Missing directories are handled by `wt prune`.
|
|
93
|
+
|
|
94
|
+
Asks for confirmation; pass --yes to skip it, or --dry-run.
|
|
95
|
+
TEXT
|
|
96
|
+
option :merged, type: :boolean, desc: 'Branches merged into the default branch'
|
|
97
|
+
option :gone, type: :boolean, desc: 'Branches whose upstream branch was deleted'
|
|
98
|
+
option :stale, type: :string, banner: 'DAYS', lazy_default: '',
|
|
99
|
+
desc: 'Branches without commits for DAYS days (default: stale_days setting)'
|
|
100
|
+
option :agent, type: :boolean, desc: 'Branches created by an agent'
|
|
101
|
+
option :force, type: :boolean, default: false,
|
|
102
|
+
desc: 'Also remove locked worktrees and worktrees with uncommitted changes'
|
|
103
|
+
def clean
|
|
104
|
+
run_command('worktree clean', Commands::WorktreeClean)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
desc 'remove [WORKTREE...]', 'Remove worktrees (paths or branch names), saving a snapshot first'
|
|
108
|
+
long_desc descriptions.worktree_remove.long
|
|
109
|
+
option :force, type: :boolean, default: false,
|
|
110
|
+
desc: 'Remove locked worktrees and worktrees with uncommitted changes'
|
|
111
|
+
def remove(*worktrees)
|
|
112
|
+
run_command('worktree remove', Commands::WorktreeRemove, *worktrees)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
# Brings back a worktree that was removed after a snapshot of it: adds the worktree again on
|
|
5
|
+
# its branch (or detached HEAD) and restores its files. Branch tips are restored before the
|
|
6
|
+
# worktree is added, so a branch deleted or moved since is checked out at its saved tip and its
|
|
7
|
+
# files always match it.
|
|
8
|
+
class WorktreeRevival
|
|
9
|
+
def initialize(snapshot, repo:)
|
|
10
|
+
@snapshot = snapshot
|
|
11
|
+
@repo = repo
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Same shape as an undo plan, plus `recreate_worktree`
|
|
15
|
+
def plan
|
|
16
|
+
head = @snapshot.head
|
|
17
|
+
{ branches: Restore.new(@snapshot).plan[:branches],
|
|
18
|
+
head: { from: nil, to: head[:branch] || head[:sha] },
|
|
19
|
+
worktree: !@snapshot.worktree.nil?, stashes: [], snapshot: @snapshot.id,
|
|
20
|
+
recreate_worktree: @snapshot.worktree_path }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def scope
|
|
24
|
+
%w[branches]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def empty?
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns nil on success or the failed step's Result
|
|
32
|
+
def apply
|
|
33
|
+
failure = Restore.new(@snapshot).apply(only: :branches)
|
|
34
|
+
return failure if failure
|
|
35
|
+
|
|
36
|
+
result = attach
|
|
37
|
+
return result unless result.success?
|
|
38
|
+
|
|
39
|
+
restore = Restore.new(@snapshot, bash: Git::InDirectory.new(@snapshot.worktree_path))
|
|
40
|
+
restore.apply
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def attach
|
|
46
|
+
worktrees = Worktrees.new(repo: @repo)
|
|
47
|
+
path = @snapshot.worktree_path
|
|
48
|
+
branch = @snapshot.head[:branch]
|
|
49
|
+
sha = @snapshot.head[:sha]
|
|
50
|
+
return worktrees.attach(path, detach_at: sha) unless branch
|
|
51
|
+
|
|
52
|
+
worktrees.attach(path, branch: branch, create_at: sha)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -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
|