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.
@@ -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
- # merged into the default branch (one extra git call).
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
@@ -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
@@ -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 taken.
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 "Undo with: gitflash undo #{fields[:undo].id}" if fields[:undo]
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gitflash
4
- VERSION = '0.7.0'
4
+ VERSION = '0.8.0.beta1'
5
5
  end