gitflash 0.7.0 → 0.8.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 +32 -0
- data/README.md +124 -114
- data/lib/gitflash/branch.rb +10 -5
- data/lib/gitflash/branch_activity.rb +47 -0
- data/lib/gitflash/cleanup.rb +42 -1
- data/lib/gitflash/cli.rb +32 -0
- data/lib/gitflash/commands/clean.rb +14 -4
- data/lib/gitflash/commands/skill_install.rb +118 -0
- data/lib/gitflash/hook/branch_marker.rb +17 -11
- data/lib/gitflash/hook/command_parser.rb +13 -6
- data/lib/gitflash/hook/heredocs.rb +120 -0
- data/lib/gitflash/hook/shell_text.rb +52 -19
- data/lib/gitflash/mcp/confirmation.rb +61 -0
- data/lib/gitflash/mcp/elicitation.rb +122 -0
- data/lib/gitflash/mcp/plan_change.rb +53 -0
- data/lib/gitflash/mcp/runner.rb +41 -0
- data/lib/gitflash/mcp/server.rb +148 -0
- data/lib/gitflash/mcp/tools.rb +195 -0
- data/lib/gitflash/repo.rb +4 -4
- data/lib/gitflash/reused_snapshot.rb +11 -0
- data/lib/gitflash/skill_cli.rb +46 -0
- data/lib/gitflash/snapshot.rb +3 -0
- data/lib/gitflash/snapshots.rb +3 -2
- data/lib/gitflash/ui.rb +8 -2
- data/lib/gitflash/version.rb +1 -1
- data/schema/v1.json +74 -0
- data/skills/gitflash/SKILL.md +95 -0
- metadata +32 -4
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'json_schemer'
|
|
5
|
+
|
|
6
|
+
module Gitflash
|
|
7
|
+
module Mcp
|
|
8
|
+
# The plan_change tool: the dry run of a destructive tool and a confirm_token for it, as a
|
|
9
|
+
# read-only call. The client has only checked that `arguments` is an object, so they are
|
|
10
|
+
# checked against the target tool's schema here.
|
|
11
|
+
class PlanChange
|
|
12
|
+
def initialize(runner, confirmation)
|
|
13
|
+
@runner = runner
|
|
14
|
+
@confirmation = confirmation
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(arguments)
|
|
18
|
+
target = Tools::ALL.find { |tool| tool.kind == :destructive && tool.name == arguments[:tool] }
|
|
19
|
+
return invalid("Unknown destructive tool #{arguments[:tool].inspect}") unless target
|
|
20
|
+
|
|
21
|
+
check(target, JSON.parse(JSON.generate(arguments[:arguments] || {})))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def check(target, arguments)
|
|
27
|
+
problems = problems(target, arguments)
|
|
28
|
+
return plan(target, arguments.transform_keys(&:to_sym).sort.to_h) if problems.empty?
|
|
29
|
+
|
|
30
|
+
invalid("Invalid arguments for #{target.name}: #{problems.join('; ')}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def plan(target, arguments)
|
|
34
|
+
preview = @runner.call([*target.argv.call(arguments), '--dry-run'])
|
|
35
|
+
return preview unless preview['status'] == 'planned'
|
|
36
|
+
|
|
37
|
+
@confirmation.required(target, arguments, preview, nil)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def problems(target, arguments)
|
|
41
|
+
JSONSchemer.schema(Tools.input_schema(target)).validate(arguments).map do |problem|
|
|
42
|
+
problem['error']
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def invalid(message)
|
|
47
|
+
{ 'schema' => Ui::JSON_SCHEMA_VERSION, 'command' => 'gitflash', 'ok' => false,
|
|
48
|
+
'status' => 'error', 'dry_run' => false,
|
|
49
|
+
'error' => { 'code' => 'invalid_options', 'message' => message, 'exit_code' => 2 } }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module Gitflash
|
|
7
|
+
module Mcp
|
|
8
|
+
# Runs one gitflash command with --json in a child process and returns its envelope.
|
|
9
|
+
# A child process keeps the MCP connection safe: commands print to stdout, which is the
|
|
10
|
+
# stdio transport here, and an error in a command can never take the server down. The child
|
|
11
|
+
# runs this same copy of gitflash, in the server's working directory, without a terminal,
|
|
12
|
+
# so it never waits for input.
|
|
13
|
+
class Runner
|
|
14
|
+
LIB = File.expand_path('../..', __dir__)
|
|
15
|
+
BIN = File.expand_path('../../../bin/gitflash', __dir__)
|
|
16
|
+
|
|
17
|
+
def initialize(dir: Dir.pwd)
|
|
18
|
+
@dir = dir
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(argv)
|
|
22
|
+
stdout, stderr, status = Open3.capture3(Gem.ruby, '-I', LIB, BIN, *argv, '--json',
|
|
23
|
+
chdir: @dir, stdin_data: '')
|
|
24
|
+
JSON.parse(stdout)
|
|
25
|
+
rescue JSON::ParserError
|
|
26
|
+
failed(argv, status, stderr)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# A command that printed no envelope, which only happens when gitflash itself breaks
|
|
32
|
+
def failed(argv, status, stderr)
|
|
33
|
+
message = "gitflash #{argv.first} printed no JSON (exit #{status&.exitstatus}): " \
|
|
34
|
+
"#{stderr.to_s.strip[0, 2000]}"
|
|
35
|
+
{ 'schema' => Ui::JSON_SCHEMA_VERSION, 'command' => argv.first, 'ok' => false,
|
|
36
|
+
'status' => 'error', 'dry_run' => false,
|
|
37
|
+
'error' => { 'code' => 'failed', 'message' => message, 'exit_code' => 1 } }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'mcp'
|
|
5
|
+
|
|
6
|
+
module Gitflash
|
|
7
|
+
module Mcp
|
|
8
|
+
# `gitflash mcp`: an MCP server on stdio for the repository in its working directory.
|
|
9
|
+
# Every tool runs a gitflash command (Runner) and answers with its JSON v1 envelope, so the
|
|
10
|
+
# tools and the CLI share one contract. A destructive tool first asks the user to approve its
|
|
11
|
+
# plan through the client (Elicitation). When the client cannot ask, it takes two calls: the
|
|
12
|
+
# first returns the plan with status `confirmation_required` and `error.confirm_token`, the
|
|
13
|
+
# second passes that token as `confirm_token` and makes exactly that change (Confirmation).
|
|
14
|
+
class Server
|
|
15
|
+
INSTRUCTIONS = <<~TEXT
|
|
16
|
+
gitflash works on the git repository this server was started in. Every result is the
|
|
17
|
+
gitflash JSON envelope (`gitflash schema` describes it): check `ok`, `status` and
|
|
18
|
+
`error.code`. Changes save a snapshot first; keep `undo.command` and tell the user how to
|
|
19
|
+
undo. To see what a destructive tool would do, call plan_change (read-only); it returns
|
|
20
|
+
the plan and a confirm_token. Destructive tools ask the user to approve the plan when
|
|
21
|
+
the client supports
|
|
22
|
+
elicitation, unless called with a confirm_token; status `cancelled` means the user
|
|
23
|
+
declined, so do not retry. Otherwise they answer the first call with the plan, status
|
|
24
|
+
`confirmation_required` and `error.confirm_token`: show the plan to the user, and only
|
|
25
|
+
when it is what they asked for, call the tool again with the same arguments and
|
|
26
|
+
`confirm_token`. A token expires after 5 minutes and only confirms that plan.
|
|
27
|
+
clean_branches selects branches by rule: unless the user approved its plan in the client,
|
|
28
|
+
show them the list and wait for their OK. Do not call save_snapshot before plain git
|
|
29
|
+
commands when the gitflash hook is installed; the hook saves one itself.
|
|
30
|
+
TEXT
|
|
31
|
+
|
|
32
|
+
ANNOTATIONS = {
|
|
33
|
+
read: { read_only_hint: true, destructive_hint: false, idempotent_hint: true },
|
|
34
|
+
additive: { read_only_hint: false, destructive_hint: false, idempotent_hint: false },
|
|
35
|
+
destructive: { read_only_hint: false, destructive_hint: true, idempotent_hint: false },
|
|
36
|
+
plan: { read_only_hint: true, destructive_hint: false, idempotent_hint: false }
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
CONFIRM_TOKEN = { type: 'string',
|
|
40
|
+
description: 'The error.confirm_token of plan_change or of the first ' \
|
|
41
|
+
'call, to make the change it planned' }.freeze
|
|
42
|
+
|
|
43
|
+
def initialize(runner: Runner.new, confirmation: Confirmation.new,
|
|
44
|
+
elicitation: Elicitation.new)
|
|
45
|
+
@runner = runner
|
|
46
|
+
@confirmation = confirmation
|
|
47
|
+
@elicitation = elicitation
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def start
|
|
51
|
+
MCP::Server::Transports::StdioTransport.new(mcp_server).open
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mcp_server
|
|
55
|
+
MCP::Server.new(name: 'gitflash', version: VERSION, instructions: INSTRUCTIONS,
|
|
56
|
+
tools: Tools::ALL.map { |tool| mcp_tool(tool) })
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The envelope a tool answers with. `context` is the MCP server context of the request,
|
|
60
|
+
# through which the user can be asked.
|
|
61
|
+
def call(tool, arguments, context = nil)
|
|
62
|
+
arguments = arguments.transform_keys(&:to_sym)
|
|
63
|
+
return destructive(tool, arguments, context) if tool.kind == :destructive
|
|
64
|
+
return PlanChange.new(@runner, @confirmation).call(arguments) if tool.kind == :plan
|
|
65
|
+
|
|
66
|
+
@runner.call(tool.argv.call(arguments))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def mcp_tool(tool)
|
|
72
|
+
server = self
|
|
73
|
+
schema = Tools.input_schema(tool)
|
|
74
|
+
schema['properties']['confirm_token'] = CONFIRM_TOKEN if tool.kind == :destructive
|
|
75
|
+
MCP::Tool.define(
|
|
76
|
+
name: tool.name, description: tool.description, input_schema: schema,
|
|
77
|
+
annotations: ANNOTATIONS.fetch(tool.kind).merge(open_world_hint: false)
|
|
78
|
+
) do |server_context: nil, **arguments|
|
|
79
|
+
result = server.call(tool, arguments, server_context)
|
|
80
|
+
result.is_a?(Hash) ? server.send(:respond, result) : result
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def respond(envelope)
|
|
85
|
+
MCP::Tool::Response.new([{ type: 'text', text: JSON.generate(envelope) }],
|
|
86
|
+
structured_content: envelope,
|
|
87
|
+
error: %w[error failed].include?(envelope['status']))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The dry run gives the plan. A valid token for it, or the user's approval, makes the
|
|
91
|
+
# change; a declined plan is cancelled; otherwise the plan is returned with a new token.
|
|
92
|
+
# Nothing to do or an error is returned as it is.
|
|
93
|
+
def destructive(tool, arguments, context)
|
|
94
|
+
token = arguments.delete(:confirm_token)
|
|
95
|
+
arguments = arguments.sort.to_h
|
|
96
|
+
argv = tool.argv.call(arguments)
|
|
97
|
+
preview = @runner.call([*argv, '--dry-run'])
|
|
98
|
+
return preview unless preview['status'] == 'planned'
|
|
99
|
+
|
|
100
|
+
plan = preview['plan']
|
|
101
|
+
return run(argv, plan) if token && @confirmation.valid?(token, tool.name, arguments, plan)
|
|
102
|
+
|
|
103
|
+
approve(tool, arguments, preview, token, context)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A token that does not fit gets a new one without asking: the agent asked for this change
|
|
107
|
+
# before, so the user is not asked twice
|
|
108
|
+
def approve(tool, arguments, preview, token, context)
|
|
109
|
+
answer = elicited(tool, arguments, preview['plan'], context) unless token
|
|
110
|
+
return answer if answer.is_a?(MCP::Server::InputRequiredResult)
|
|
111
|
+
return run(tool.argv.call(arguments), preview['plan']) if answer == :accepted
|
|
112
|
+
return declined(preview) if answer == :declined
|
|
113
|
+
|
|
114
|
+
@confirmation.required(tool, arguments, preview, token)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The form's request state is a token signed for `form:TOOL`, so the client's echo binds the
|
|
118
|
+
# answer to this plan and expires like a confirm_token, and it cannot be passed off as a
|
|
119
|
+
# confirm_token itself
|
|
120
|
+
def elicited(tool, arguments, plan, context)
|
|
121
|
+
name = "form:#{tool.name}"
|
|
122
|
+
state = context.request_state if context.respond_to?(:request_state)
|
|
123
|
+
unless state
|
|
124
|
+
issue = -> { @confirmation.issue(name, arguments, plan) }
|
|
125
|
+
return @elicitation.ask(context, tool, plan, state: issue)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
@elicitation.answer(context, tool, fits: @confirmation.valid?(state, name, arguments, plan))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def declined(preview)
|
|
132
|
+
preview.merge('status' => 'cancelled', 'dry_run' => false)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# --yes covers changes undo can revert. A change it cannot revert (removing a worktree with
|
|
136
|
+
# ignored files) asks for the command's own token; the MCP token confirmed the same plan,
|
|
137
|
+
# so it is passed on, but only when the command still plans exactly that.
|
|
138
|
+
def run(argv, plan)
|
|
139
|
+
result = @runner.call([*argv, '--yes'])
|
|
140
|
+
cli_token = result.dig('error', 'confirm_token')
|
|
141
|
+
return result unless result['status'] == 'confirmation_required' && cli_token
|
|
142
|
+
return result unless result['plan'] == plan
|
|
143
|
+
|
|
144
|
+
@runner.call([*argv, '--confirm', cli_token])
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
module Mcp
|
|
7
|
+
# The tools `gitflash mcp` offers. Each one is a gitflash command; `argv` turns the tool's
|
|
8
|
+
# arguments into that command's words. The kind decides how it runs (see Server):
|
|
9
|
+
# read reads only
|
|
10
|
+
# additive changes the repository in a way that loses nothing; runs at once
|
|
11
|
+
# destructive can discard work; the first call returns the plan and a confirm_token,
|
|
12
|
+
# a second call with the token makes the change. A snapshot is always
|
|
13
|
+
# saved first, so `undo` can bring it back.
|
|
14
|
+
# plan plan_change: the plan and token of a destructive tool, as a read-only tool,
|
|
15
|
+
# so a client that asks before every destructive call asks only once
|
|
16
|
+
module Tools
|
|
17
|
+
Tool = Data.define(:name, :kind, :description, :properties, :required, :argv)
|
|
18
|
+
|
|
19
|
+
# Names, SHAs and paths become command words, so one that starts with a dash could turn
|
|
20
|
+
# into an option
|
|
21
|
+
NAME = { type: 'string', minLength: 1, pattern: '^[^-]' }.freeze
|
|
22
|
+
NAMES = { type: 'array', items: NAME, minItems: 1 }.freeze
|
|
23
|
+
DAYS = { type: 'integer', minimum: 1 }.freeze
|
|
24
|
+
|
|
25
|
+
# --merged, --gone, --stale DAYS, ... from the boolean and number arguments that are set
|
|
26
|
+
def self.flags(args, *names)
|
|
27
|
+
names.flat_map do |name|
|
|
28
|
+
value = args[name]
|
|
29
|
+
flag = "--#{name.to_s.delete_suffix('_days').tr('_', '-')}"
|
|
30
|
+
if value.is_a?(Integer) then [flag, value.to_s]
|
|
31
|
+
elsif value == true then [flag]
|
|
32
|
+
else []
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
FILTERS = {
|
|
38
|
+
merged: { type: 'boolean', description: 'Branches merged into the default branch' },
|
|
39
|
+
gone: { type: 'boolean', description: 'Branches whose upstream branch was deleted' },
|
|
40
|
+
stale_days: DAYS.merge(description: 'Branches without commits for this many days')
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
COMMANDS = [
|
|
44
|
+
Tool.new(
|
|
45
|
+
name: 'list_branches', kind: :read,
|
|
46
|
+
description: 'List local branches with last commit, upstream (ahead, behind, gone), ' \
|
|
47
|
+
'merge status and owner (agent or human). A branch is listed when it ' \
|
|
48
|
+
'matches any filter given.',
|
|
49
|
+
properties: FILTERS, required: [],
|
|
50
|
+
argv: ->(a) { ['branches', *flags(a, :merged, :gone, :stale_days)] }
|
|
51
|
+
),
|
|
52
|
+
Tool.new(
|
|
53
|
+
name: 'list_worktrees', kind: :read,
|
|
54
|
+
description: 'List worktrees with absolute path, branch, HEAD, dirty state, ' \
|
|
55
|
+
'ahead/behind, merge status, lock state and owner.',
|
|
56
|
+
properties: {}, required: [], argv: ->(_) { %w[worktree list] }
|
|
57
|
+
),
|
|
58
|
+
Tool.new(
|
|
59
|
+
name: 'list_pickable_commits', kind: :read,
|
|
60
|
+
description: 'List the commits of a source branch that are not on the current branch ' \
|
|
61
|
+
'yet. Commits whose patch is already here are marked.',
|
|
62
|
+
properties: { source: NAME.merge(description: 'Branch to pick from') },
|
|
63
|
+
required: %w[source], argv: ->(a) { ['pick', a[:source], '--list'] }
|
|
64
|
+
),
|
|
65
|
+
Tool.new(
|
|
66
|
+
name: 'list_snapshots', kind: :read,
|
|
67
|
+
description: 'List the snapshots gitflash saved before changes, newest first.',
|
|
68
|
+
properties: {}, required: [], argv: ->(_) { %w[snapshots] }
|
|
69
|
+
),
|
|
70
|
+
Tool.new(
|
|
71
|
+
name: 'add_worktree', kind: :additive,
|
|
72
|
+
description: 'Add a worktree for a branch: an existing local branch, a branch that ' \
|
|
73
|
+
'only exists on origin, or a new branch (from HEAD or `from`). New ' \
|
|
74
|
+
'branches are marked as agent work. The result holds the absolute path.',
|
|
75
|
+
properties: { branch: NAME, from: NAME.merge(description: 'Start a new branch here'),
|
|
76
|
+
path: NAME.merge(description: 'Directory (default from worktree_dir)') },
|
|
77
|
+
required: %w[branch],
|
|
78
|
+
argv: lambda { |a|
|
|
79
|
+
['worktree', 'add', a[:branch], *(a[:from] ? ['--from', a[:from]] : []),
|
|
80
|
+
*(a[:path] ? ['--path', a[:path]] : [])]
|
|
81
|
+
}
|
|
82
|
+
),
|
|
83
|
+
Tool.new(
|
|
84
|
+
name: 'cherry_pick_commits', kind: :additive,
|
|
85
|
+
description: 'Apply commits of a source branch to the current branch, oldest first, ' \
|
|
86
|
+
'with `git cherry-pick -x`, after a snapshot. On a conflict it stops with ' \
|
|
87
|
+
'error code `conflict` and lists the files; then use cherry_pick_control.',
|
|
88
|
+
properties: { source: NAME, shas: NAMES,
|
|
89
|
+
commit: { type: 'boolean',
|
|
90
|
+
description: 'false only stages the changes (default true)' } },
|
|
91
|
+
required: %w[source shas],
|
|
92
|
+
argv: lambda { |a|
|
|
93
|
+
['pick', a[:source], *a[:shas], '--yes', *(a[:commit] == false ? ['--no-commit'] : [])]
|
|
94
|
+
}
|
|
95
|
+
),
|
|
96
|
+
Tool.new(
|
|
97
|
+
name: 'cherry_pick_control', kind: :additive,
|
|
98
|
+
description: 'Continue a stopped pick after resolving its conflicts, skip the commit ' \
|
|
99
|
+
'that conflicts, or abort and return to the state before the pick.',
|
|
100
|
+
properties: { action: { enum: %w[continue skip abort] } }, required: %w[action],
|
|
101
|
+
argv: ->(a) { ['pick', "--#{a[:action]}", '--yes'] }
|
|
102
|
+
),
|
|
103
|
+
Tool.new(
|
|
104
|
+
name: 'mark_branches', kind: :additive,
|
|
105
|
+
description: 'Mark branches as agent or human work (or clear the mark), so ' \
|
|
106
|
+
'clean_branches with `agent` can find agent branches.',
|
|
107
|
+
properties: { branches: NAMES, owner: { enum: Ownership::OWNERS },
|
|
108
|
+
clear: { type: 'boolean' } },
|
|
109
|
+
required: %w[branches],
|
|
110
|
+
argv: lambda { |a|
|
|
111
|
+
['mark', *a[:branches], *(a[:clear] ? ['--clear'] : ['--owner', a[:owner] || 'agent'])]
|
|
112
|
+
}
|
|
113
|
+
),
|
|
114
|
+
Tool.new(
|
|
115
|
+
name: 'save_snapshot', kind: :additive,
|
|
116
|
+
description: 'Save the current branches, HEAD, files and stashes, so undo can come ' \
|
|
117
|
+
'back here. Not needed before plain git commands when the gitflash ' \
|
|
118
|
+
'hook is installed: it saves a snapshot itself.',
|
|
119
|
+
properties: { message: { type: 'string', description: 'Why the snapshot is taken' } },
|
|
120
|
+
required: [],
|
|
121
|
+
argv: ->(a) { ['snapshot', *(a[:message] ? ['--message', a[:message]] : [])] }
|
|
122
|
+
),
|
|
123
|
+
Tool.new(
|
|
124
|
+
name: 'delete_branches', kind: :destructive,
|
|
125
|
+
description: 'Delete local branches. Protected branches are never deleted; unmerged ' \
|
|
126
|
+
'ones only with `force`.',
|
|
127
|
+
properties: { branches: NAMES, force: { type: 'boolean' } }, required: %w[branches],
|
|
128
|
+
argv: ->(a) { ['delete', *a[:branches], *flags(a, :force)] }
|
|
129
|
+
),
|
|
130
|
+
Tool.new(
|
|
131
|
+
name: 'clean_branches', kind: :destructive,
|
|
132
|
+
description: 'Delete merged branches and branches whose upstream is gone, and with ' \
|
|
133
|
+
'`stale_days` or `agent` also stale or agent-created ones. Unmerged ones ' \
|
|
134
|
+
'only with `force`. Protected branches are never deleted.',
|
|
135
|
+
properties: FILTERS.merge(agent: { type: 'boolean', description: 'Agent branches' },
|
|
136
|
+
force: { type: 'boolean' }),
|
|
137
|
+
required: [],
|
|
138
|
+
argv: ->(a) { ['clean', *flags(a, :merged, :gone, :stale_days, :agent, :force)] }
|
|
139
|
+
),
|
|
140
|
+
Tool.new(
|
|
141
|
+
name: 'reset_branch', kind: :destructive,
|
|
142
|
+
description: 'Reset the current branch to a commit: mixed (default) keeps changes ' \
|
|
143
|
+
'unstaged, soft keeps them staged, hard discards them.',
|
|
144
|
+
properties: { commit: NAME, mode: { enum: %w[mixed soft hard] } }, required: %w[commit],
|
|
145
|
+
argv: lambda { |a|
|
|
146
|
+
['reset', a[:commit], *(%w[soft hard].include?(a[:mode]) ? ["--#{a[:mode]}"] : [])]
|
|
147
|
+
}
|
|
148
|
+
),
|
|
149
|
+
Tool.new(
|
|
150
|
+
name: 'remove_worktrees', kind: :destructive,
|
|
151
|
+
description: 'Remove worktrees, given as paths or branch names. Dirty or locked ones ' \
|
|
152
|
+
'only with `force`. Ignored files in them are deleted and no snapshot ' \
|
|
153
|
+
'saves them: the plan lists them.',
|
|
154
|
+
properties: { worktrees: NAMES, force: { type: 'boolean' } }, required: %w[worktrees],
|
|
155
|
+
argv: ->(a) { ['worktree', 'remove', *a[:worktrees], *flags(a, :force)] }
|
|
156
|
+
),
|
|
157
|
+
Tool.new(
|
|
158
|
+
name: 'move_worktree', kind: :destructive,
|
|
159
|
+
description: 'Move a worktree (path or branch name) to another directory.',
|
|
160
|
+
properties: { worktree: NAME, path: NAME, force: { type: 'boolean' } },
|
|
161
|
+
required: %w[worktree path],
|
|
162
|
+
argv: ->(a) { ['worktree', 'move', a[:worktree], a[:path], *flags(a, :force)] }
|
|
163
|
+
),
|
|
164
|
+
Tool.new(
|
|
165
|
+
name: 'undo', kind: :destructive,
|
|
166
|
+
description: 'Restore a snapshot: by default the latest one taken in this worktree. ' \
|
|
167
|
+
'The current state is saved first, so an undo can be undone.',
|
|
168
|
+
properties: { snapshot: NAME.merge(description: 'Snapshot id from a result or ' \
|
|
169
|
+
'list_snapshots') },
|
|
170
|
+
required: [], argv: ->(a) { ['undo', *[a[:snapshot]].compact] }
|
|
171
|
+
)
|
|
172
|
+
].freeze
|
|
173
|
+
|
|
174
|
+
PLAN = Tool.new(
|
|
175
|
+
name: 'plan_change', kind: :plan,
|
|
176
|
+
description: 'Show what a destructive tool would do, without changing anything: returns ' \
|
|
177
|
+
'its plan with status confirmation_required and error.confirm_token. Then ' \
|
|
178
|
+
'call that tool with the same arguments and confirm_token to make the change.',
|
|
179
|
+
properties: {
|
|
180
|
+
tool: { enum: COMMANDS.select { |tool| tool.kind == :destructive }.map(&:name) },
|
|
181
|
+
arguments: { type: 'object', description: "The destructive tool's arguments" }
|
|
182
|
+
},
|
|
183
|
+
required: %w[tool arguments], argv: nil
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
ALL = [*COMMANDS, PLAN].freeze
|
|
187
|
+
|
|
188
|
+
# The JSON Schema of a tool's arguments, with string keys
|
|
189
|
+
def self.input_schema(tool)
|
|
190
|
+
JSON.parse(JSON.generate({ type: 'object', properties: tool.properties,
|
|
191
|
+
required: tool.required, additionalProperties: false }))
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
data/lib/gitflash/repo.rb
CHANGED
|
@@ -26,11 +26,12 @@ module Gitflash
|
|
|
26
26
|
raise Error.new('Not a git repository', code: 'not_a_repository')
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
# Local branches. With `merged_status: true` each branch also reports whether it is
|
|
30
|
-
#
|
|
29
|
+
# Local branches. With `merged_status: true` each branch also reports whether it is merged
|
|
30
|
+
# into the default branch and what its reflog says (BranchActivity): two extra git calls.
|
|
31
31
|
def branches(merged_status: false)
|
|
32
32
|
merged_names = merged_status ? merged_branch_names : nil
|
|
33
33
|
list = branch_lines.map { |line| Branch.parse(line, default_branch:, merged_names:) }
|
|
34
|
+
list = BranchActivity.annotate(list, bash: @bash) if merged_status
|
|
34
35
|
Ownership.new(bash: @bash).annotate(list)
|
|
35
36
|
end
|
|
36
37
|
|
|
@@ -104,8 +105,7 @@ module Gitflash
|
|
|
104
105
|
end
|
|
105
106
|
|
|
106
107
|
def branch_lines
|
|
107
|
-
@branch_lines ||= @bash.exec('git', 'for-each-ref', "--format=#{BRANCH_FORMAT}",
|
|
108
|
-
HEADS)
|
|
108
|
+
@branch_lines ||= @bash.exec('git', 'for-each-ref', "--format=#{BRANCH_FORMAT}", HEADS)
|
|
109
109
|
.each_line(chomp: true).reject(&:empty?)
|
|
110
110
|
end
|
|
111
111
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'delegate'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
# A snapshot `Snapshots#create` handed back because the state had not changed since it was
|
|
7
|
+
# taken. Output says so, so its older id is not mistaken for a missing snapshot.
|
|
8
|
+
class ReusedSnapshot < SimpleDelegator
|
|
9
|
+
def reused? = true
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
# `gitflash skill ...`: the Agent Skill that tells agents how to use gitflash
|
|
7
|
+
class SkillCli < Thor
|
|
8
|
+
include CommandRunner
|
|
9
|
+
|
|
10
|
+
namespace 'skill'
|
|
11
|
+
|
|
12
|
+
def self.exit_on_failure?
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
desc 'install', 'Copy the gitflash Agent Skill into an agent skills directory'
|
|
17
|
+
long_desc <<~TEXT
|
|
18
|
+
Copies SKILL.md, which explains to agents when to use gitflash, the --json envelope, the
|
|
19
|
+
plan then --yes flow and undo, into a skills directory:
|
|
20
|
+
|
|
21
|
+
--scope user ~/.claude/skills/gitflash/SKILL.md (default); every Claude Code session
|
|
22
|
+
--scope project .claude/skills/gitflash/SKILL.md at the repository top level, to commit
|
|
23
|
+
and share with your team
|
|
24
|
+
--path DIR DIR/gitflash/SKILL.md, for any agent that reads skills from DIR
|
|
25
|
+
|
|
26
|
+
Does nothing when the installed skill is the one of this version. Run it again after
|
|
27
|
+
upgrading gitflash. A file that differs may hold your own edits, which gitflash undo cannot
|
|
28
|
+
bring back: replacing it needs --confirm TOKEN (the token the refusal names), or typing the
|
|
29
|
+
token in a terminal.
|
|
30
|
+
|
|
31
|
+
Accepts the global --json and --dry-run options after the subcommand: gitflash skill install --json
|
|
32
|
+
TEXT
|
|
33
|
+
option :scope, type: :string, enum: Commands::SkillInstall::SCOPES,
|
|
34
|
+
desc: 'user or project (default: user)'
|
|
35
|
+
option :path, type: :string, banner: 'DIR', desc: 'Install into DIR/gitflash instead'
|
|
36
|
+
def install
|
|
37
|
+
run_command('skill install', Commands::SkillInstall,
|
|
38
|
+
repo_required: options[:scope] == 'project')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
desc 'show', 'Print the gitflash Agent Skill'
|
|
42
|
+
def show
|
|
43
|
+
puts File.read(Commands::SkillInstall::SOURCE)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/gitflash/snapshot.rb
CHANGED
|
@@ -28,6 +28,9 @@ module Gitflash
|
|
|
28
28
|
)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# True for a snapshot `Snapshots#create` handed back instead of saving the same state again
|
|
32
|
+
def reused? = false
|
|
33
|
+
|
|
31
34
|
# Untracked files this snapshot cannot bring back
|
|
32
35
|
def unsaved_files
|
|
33
36
|
skipped_files + excluded_files
|
data/lib/gitflash/snapshots.rb
CHANGED
|
@@ -22,11 +22,12 @@ module Gitflash
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
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
|
|
25
|
+
# Returns the latest snapshot instead, marked `reused?`, when nothing changed since it was
|
|
26
|
+
# taken: its id is older than the change, but undo with it restores the state before it.
|
|
26
27
|
def create(reason:, scope: SCOPES, branches: nil)
|
|
27
28
|
state = @capture.call(scope, branches)
|
|
28
29
|
latest = list.first
|
|
29
|
-
return latest if latest&.state == state
|
|
30
|
+
return ReusedSnapshot.new(latest) if latest&.state == state
|
|
30
31
|
|
|
31
32
|
store(Snapshot.new(**identity, reason: reason, **state))
|
|
32
33
|
end
|
data/lib/gitflash/ui.rb
CHANGED
|
@@ -41,7 +41,7 @@ module Gitflash
|
|
|
41
41
|
$stdout.puts JSON.generate(envelope(status: status, **fields))
|
|
42
42
|
else
|
|
43
43
|
$stdout.puts TerminalText.safe(text)
|
|
44
|
-
$stdout.puts
|
|
44
|
+
$stdout.puts undo_hint(fields[:undo]) if fields[:undo]
|
|
45
45
|
end
|
|
46
46
|
OK_STATUSES.include?(status) ? 0 : 1
|
|
47
47
|
end
|
|
@@ -111,11 +111,17 @@ module Gitflash
|
|
|
111
111
|
dry_run: dry_run?,
|
|
112
112
|
plan: plan,
|
|
113
113
|
result: result,
|
|
114
|
-
undo: undo && { snapshot: undo.id, command: "gitflash undo #{undo.id}"
|
|
114
|
+
undo: undo && { snapshot: undo.id, command: "gitflash undo #{undo.id}",
|
|
115
|
+
reused: (true if undo.reused?) }.compact,
|
|
115
116
|
error: error
|
|
116
117
|
}.compact
|
|
117
118
|
end
|
|
118
119
|
|
|
120
|
+
def undo_hint(undo)
|
|
121
|
+
note = undo.reused? ? ' (an earlier snapshot: nothing had changed since it was taken)' : ''
|
|
122
|
+
"Undo with: gitflash undo #{undo.id}#{note}"
|
|
123
|
+
end
|
|
124
|
+
|
|
119
125
|
def prompt
|
|
120
126
|
@prompt ||= Prompt.create
|
|
121
127
|
end
|
data/lib/gitflash/version.rb
CHANGED