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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +69 -2
  4. data/bin/gitflash +10 -0
  5. data/command_descriptions.yml +99 -0
  6. data/lib/gitflash/branch.rb +14 -2
  7. data/lib/gitflash/cherry_pick.rb +128 -0
  8. data/lib/gitflash/cleanup.rb +70 -0
  9. data/lib/gitflash/cli.rb +72 -21
  10. data/lib/gitflash/command_runner.rb +23 -0
  11. data/lib/gitflash/commands/base.rb +15 -0
  12. data/lib/gitflash/commands/branches.rb +1 -0
  13. data/lib/gitflash/commands/checkout.rb +2 -1
  14. data/lib/gitflash/commands/clean.rb +51 -0
  15. data/lib/gitflash/commands/clean_criteria.rb +29 -0
  16. data/lib/gitflash/commands/delete.rb +23 -8
  17. data/lib/gitflash/commands/gc.rb +41 -0
  18. data/lib/gitflash/commands/hook_install.rb +77 -0
  19. data/lib/gitflash/commands/hook_status.rb +80 -0
  20. data/lib/gitflash/commands/mark.rb +85 -0
  21. data/lib/gitflash/commands/pick.rb +131 -0
  22. data/lib/gitflash/commands/pick_control.rb +67 -0
  23. data/lib/gitflash/commands/pick_outcome.rb +52 -0
  24. data/lib/gitflash/commands/removal_report.rb +65 -0
  25. data/lib/gitflash/commands/reset.rb +7 -1
  26. data/lib/gitflash/commands/snapshot_create.rb +30 -0
  27. data/lib/gitflash/commands/snapshot_list.rb +20 -0
  28. data/lib/gitflash/commands/undo.rb +131 -0
  29. data/lib/gitflash/commands/worktree_add.rb +98 -0
  30. data/lib/gitflash/commands/worktree_clean.rb +49 -0
  31. data/lib/gitflash/commands/worktree_command.rb +28 -0
  32. data/lib/gitflash/commands/worktree_list.rb +48 -0
  33. data/lib/gitflash/commands/worktree_lock.rb +54 -0
  34. data/lib/gitflash/commands/worktree_move.rb +47 -0
  35. data/lib/gitflash/commands/worktree_prune.rb +41 -0
  36. data/lib/gitflash/commands/worktree_remove.rb +119 -0
  37. data/lib/gitflash/commands/worktree_unlock.rb +12 -0
  38. data/lib/gitflash/config.rb +95 -0
  39. data/lib/gitflash/config_keys.rb +19 -0
  40. data/lib/gitflash/git/bash_command.rb +5 -4
  41. data/lib/gitflash/git/in_directory.rb +29 -0
  42. data/lib/gitflash/hook/branch_marker.rb +52 -0
  43. data/lib/gitflash/hook/claude.rb +126 -0
  44. data/lib/gitflash/hook/command_parser.rb +121 -0
  45. data/lib/gitflash/hook/creations.rb +85 -0
  46. data/lib/gitflash/hook/rules.rb +108 -0
  47. data/lib/gitflash/hook/settings.rb +73 -0
  48. data/lib/gitflash/hook_cli.rb +77 -0
  49. data/lib/gitflash/ownership.rb +59 -0
  50. data/lib/gitflash/protection.rb +19 -0
  51. data/lib/gitflash/repo.rb +12 -3
  52. data/lib/gitflash/restore.rb +141 -0
  53. data/lib/gitflash/snapshot.rb +47 -0
  54. data/lib/gitflash/snapshots.rb +70 -0
  55. data/lib/gitflash/state_capture.rb +129 -0
  56. data/lib/gitflash/ui.rb +7 -5
  57. data/lib/gitflash/version.rb +1 -1
  58. data/lib/gitflash/worktree.rb +32 -0
  59. data/lib/gitflash/worktree_cli.rb +115 -0
  60. data/lib/gitflash/worktree_revival.rb +55 -0
  61. data/lib/gitflash/worktrees.rb +147 -0
  62. data/schema/v1.json +1885 -102
  63. 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
- def report(status:, text:, plan: nil, result: nil, error: nil)
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
- document = envelope(status: status, plan: plan, result: result, error: error)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gitflash
4
- VERSION = '0.5.1'
4
+ VERSION = '0.7.0.beta1'
5
5
  end
@@ -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