openclacky 1.5.21 → 1.5.22
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 +61 -0
- data/lib/clacky/billing/platform_billing.rb +3 -0
- data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
- data/lib/clacky/providers.rb +43 -17
- data/lib/clacky/search_config.rb +33 -11
- data/lib/clacky/server/git_panel.rb +360 -34
- data/lib/clacky/server/http_server.rb +145 -47
- data/lib/clacky/server/preview.rb +118 -17
- data/lib/clacky/server/scheduler.rb +81 -2
- data/lib/clacky/server/web_ui_controller.rb +35 -27
- data/lib/clacky/session_manager.rb +52 -5
- data/lib/clacky/utils/mac_app_detector.rb +8 -7
- data/lib/clacky/utils/model_pricing.rb +60 -2
- data/lib/clacky/utils/windows_app_detector.rb +3 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +650 -96
- data/lib/clacky/web/app.js +59 -6
- data/lib/clacky/web/auth.js +1 -1
- data/lib/clacky/web/components/composer.js +107 -8
- data/lib/clacky/web/components/image-zoom.js +165 -0
- data/lib/clacky/web/components/model-picker.js +6 -4
- data/lib/clacky/web/components/notify.js +2 -2
- data/lib/clacky/web/core/aside.js +60 -3
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/channels/view.js +5 -2
- data/lib/clacky/web/features/extensions/view.js +2 -2
- data/lib/clacky/web/features/new-session/store.js +79 -2
- data/lib/clacky/web/features/new-session/view.js +496 -58
- data/lib/clacky/web/features/skills/store.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +162 -51
- data/lib/clacky/web/features/workspace/store.js +13 -6
- data/lib/clacky/web/features/workspace/view.js +153 -163
- data/lib/clacky/web/i18n.js +238 -56
- data/lib/clacky/web/index.html +173 -157
- data/lib/clacky/web/projects.js +37 -4
- data/lib/clacky/web/sessions.js +181 -105
- data/lib/clacky/web/settings.js +26 -8
- data/lib/clacky/web/skills.js +2 -2
- data/lib/clacky/web/theme.js +17 -2
- data/lib/clacky/web/utils.js +17 -2
- data/lib/clacky/web/weixin-qr.html +2 -1
- data/lib/clacky/web/ws-dispatcher.js +8 -29
- metadata +2 -1
|
@@ -8,12 +8,18 @@ module Clacky
|
|
|
8
8
|
module Server
|
|
9
9
|
# Read-mostly git operations scoped to a session's working directory, backing
|
|
10
10
|
# the official "git" WebUI panel. Commands run with explicit argv (no shell),
|
|
11
|
-
# so user-supplied values (paths, messages) cannot inject.
|
|
12
|
-
#
|
|
11
|
+
# so user-supplied values (paths, messages) cannot inject. The only write
|
|
12
|
+
# operation is a single-file `restore`; committing is left to the agent, and
|
|
13
13
|
# history-rewriting / remote-mutating commands are never exposed here.
|
|
14
14
|
module GitPanel
|
|
15
15
|
module_function
|
|
16
16
|
|
|
17
|
+
# Whole-payload diff budget. A branch comparison against a long-lived
|
|
18
|
+
# branch can be 900 files / 200k lines; serialising that and building the
|
|
19
|
+
# DOM for it locks up the browser, so files past this budget are returned
|
|
20
|
+
# without their diff and the UI loads them on demand.
|
|
21
|
+
DEFAULT_MAX_TOTAL_LINES = 12_000
|
|
22
|
+
|
|
17
23
|
# Run a git subcommand in `dir` with argv-style args (no shell). Returns
|
|
18
24
|
# [stdout, stderr, success_bool]. Never raises on git failure.
|
|
19
25
|
#
|
|
@@ -36,9 +42,11 @@ module Clacky
|
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
# { branch:, ahead:, behind:, files: [{ path:, x:, y:, staged:, untracked: }] }
|
|
39
|
-
# Parsed from `git status --porcelain=v2 --branch`.
|
|
45
|
+
# Parsed from `git status --porcelain=v2 --branch`. Untracked files are
|
|
46
|
+
# listed individually (`--untracked-files=all`): the default collapses a
|
|
47
|
+
# new directory into one `dir/` entry, which the panel cannot diff.
|
|
40
48
|
def status(dir)
|
|
41
|
-
out, _err, ok = git(dir, "status", "--porcelain=v2", "--branch")
|
|
49
|
+
out, _err, ok = git(dir, "status", "--porcelain=v2", "--branch", "--untracked-files=all")
|
|
42
50
|
return { branch: nil, files: [] } unless ok
|
|
43
51
|
|
|
44
52
|
branch = nil
|
|
@@ -70,25 +78,265 @@ module Clacky
|
|
|
70
78
|
# they diff against /dev/null via --no-index (rendered as pure
|
|
71
79
|
# additions); the trailing `--` keeps a leading-dash filename from
|
|
72
80
|
# being read as an option in that mode too.
|
|
73
|
-
|
|
81
|
+
# `context` overrides the default three lines of surrounding code; the
|
|
82
|
+
# changes panel passes a huge value to reveal the whole file behind a
|
|
83
|
+
# collapsed "N unmodified lines" gap.
|
|
84
|
+
def diff(dir, file: nil, context: nil, scope: "worktree", base: nil)
|
|
85
|
+
extra = context ? ["-U#{context.to_i.clamp(0, 1_000_000).to_s}"] : []
|
|
86
|
+
range = diff_range(dir, scope, base)
|
|
74
87
|
if file && !file.empty?
|
|
75
88
|
others, _e, _ok = git(dir, "ls-files", "--others", "--exclude-standard", "--", file)
|
|
76
89
|
if others.strip.empty?
|
|
77
|
-
out, _e, _ok = git(dir, "diff",
|
|
90
|
+
out, _e, _ok = git(dir, "diff", *range, *extra, "--", file)
|
|
91
|
+
elsif scope.to_s == "worktree" || scope.to_s == "unstaged"
|
|
92
|
+
out, _e, _ok = git(dir, "diff", "--no-index", *extra, "--", "/dev/null", file)
|
|
78
93
|
else
|
|
79
|
-
out
|
|
94
|
+
out = ""
|
|
80
95
|
end
|
|
81
96
|
out
|
|
82
97
|
else
|
|
83
|
-
git(dir, "diff",
|
|
98
|
+
git(dir, "diff", *range, *extra)[0]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Baseline args for `git diff` in the given scope. `worktree` keeps the
|
|
103
|
+
# historical behaviour (HEAD); `committed` reviews a single commit the
|
|
104
|
+
# user picked (or the whole unpushed range); `branch` compares the merge
|
|
105
|
+
# base of the chosen branch so unrelated commits on it do not leak in.
|
|
106
|
+
def diff_range(dir, scope, base)
|
|
107
|
+
case scope.to_s
|
|
108
|
+
when "unstaged" then []
|
|
109
|
+
when "staged" then ["--cached", "HEAD"]
|
|
110
|
+
when "committed"
|
|
111
|
+
base_ref = base.to_s.strip
|
|
112
|
+
if base_ref.empty?
|
|
113
|
+
upstream = default_base(dir)
|
|
114
|
+
ref?(dir, upstream) ? ["#{upstream}...HEAD"] : []
|
|
115
|
+
else
|
|
116
|
+
commit_range(dir, base_ref)
|
|
117
|
+
end
|
|
118
|
+
when "branch"
|
|
119
|
+
base_ref = base.to_s.strip
|
|
120
|
+
base_ref = default_base(dir) if base_ref.empty?
|
|
121
|
+
ref?(dir, base_ref) ? ["#{base_ref}...HEAD"] : []
|
|
122
|
+
else ["HEAD"]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A commit picked in the "committed" menu is reviewed on its own: the
|
|
127
|
+
# panel answers "what did this commit change", not "what came after it".
|
|
128
|
+
# Root commits have no parent, so they diff against the empty tree.
|
|
129
|
+
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
130
|
+
|
|
131
|
+
def commit_range(dir, sha)
|
|
132
|
+
return [] unless ref?(dir, sha)
|
|
133
|
+
|
|
134
|
+
parent = "#{sha}^"
|
|
135
|
+
ref?(dir, parent) ? ["#{parent}..#{sha}"] : [EMPTY_TREE, sha]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Review scopes, mirroring the selector above the changes panel:
|
|
139
|
+
# worktree - everything not committed yet (baseline HEAD)
|
|
140
|
+
# unstaged - index vs working tree
|
|
141
|
+
# staged - HEAD vs index
|
|
142
|
+
# committed - the branch's commits on top of its base (upstream)
|
|
143
|
+
# branch - the same comparison against a branch the user picked
|
|
144
|
+
# lastTurn - what the previous agent turn touched (served from the
|
|
145
|
+
# Time Machine snapshots by `last_turn_review`, not git)
|
|
146
|
+
SCOPES = %w[worktree unstaged staged committed branch lastTurn].freeze
|
|
147
|
+
|
|
148
|
+
# One payload for the whole changes panel: status order, per-file unified
|
|
149
|
+
# diff, and per-file added/removed line counts. Tracked changes come from
|
|
150
|
+
# a single diff split on its `diff --git` headers so N files cost one
|
|
151
|
+
# subprocess; untracked files have no baseline and are diffed against
|
|
152
|
+
# /dev/null individually (they are usually few).
|
|
153
|
+
#
|
|
154
|
+
# `max_lines` caps each file's diff so a minified bundle cannot stall the
|
|
155
|
+
# browser; `truncated` tells the UI to say so instead of rendering half a
|
|
156
|
+
# hunk silently.
|
|
157
|
+
def review(dir, max_lines: 4000, max_total_lines: DEFAULT_MAX_TOTAL_LINES, scope: nil, base: nil)
|
|
158
|
+
return { repo: false } unless repo?(dir)
|
|
159
|
+
|
|
160
|
+
scope = SCOPES.include?(scope.to_s) ? scope.to_s : "worktree"
|
|
161
|
+
st = status(dir)
|
|
162
|
+
payload = { repo: true, branch: st[:branch], ahead: st[:ahead],
|
|
163
|
+
behind: st[:behind], scope: scope }
|
|
164
|
+
|
|
165
|
+
case scope
|
|
166
|
+
when "unstaged"
|
|
167
|
+
payload.merge!(unstaged_review(dir, st, max_lines: max_lines))
|
|
168
|
+
when "staged"
|
|
169
|
+
payload.merge!(range_review(dir, ["--cached", "HEAD"], max_lines: max_lines,
|
|
170
|
+
max_total_lines: max_total_lines))
|
|
171
|
+
when "committed"
|
|
172
|
+
base_ref = base.to_s.strip
|
|
173
|
+
if base_ref.empty?
|
|
174
|
+
upstream = default_base(dir)
|
|
175
|
+
if upstream.empty? || !ref?(dir, upstream)
|
|
176
|
+
payload.merge!(files: [], add: 0, del: 0, note: "noBase")
|
|
177
|
+
else
|
|
178
|
+
payload[:base] = upstream
|
|
179
|
+
payload.merge!(range_review(dir, ["#{upstream}...HEAD"], max_lines: max_lines,
|
|
180
|
+
max_total_lines: max_total_lines))
|
|
181
|
+
end
|
|
182
|
+
else
|
|
183
|
+
range = commit_range(dir, base_ref)
|
|
184
|
+
if range.empty?
|
|
185
|
+
payload.merge!(files: [], add: 0, del: 0, note: "noBase")
|
|
186
|
+
else
|
|
187
|
+
payload[:commit] = base_ref
|
|
188
|
+
payload.merge!(range_review(dir, range, max_lines: max_lines,
|
|
189
|
+
max_total_lines: max_total_lines))
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
when "branch"
|
|
193
|
+
base_ref = base.to_s.strip
|
|
194
|
+
base_ref = default_base(dir) if base_ref.empty?
|
|
195
|
+
if base_ref.empty? || !ref?(dir, base_ref)
|
|
196
|
+
payload.merge!(files: [], add: 0, del: 0, note: "noBase")
|
|
197
|
+
else
|
|
198
|
+
payload[:base] = base_ref
|
|
199
|
+
payload.merge!(range_review(dir, ["#{base_ref}...HEAD"], max_lines: max_lines,
|
|
200
|
+
max_total_lines: max_total_lines))
|
|
201
|
+
end
|
|
202
|
+
else
|
|
203
|
+
payload.merge!(worktree_review(dir, st, max_lines: max_lines,
|
|
204
|
+
max_total_lines: max_total_lines))
|
|
84
205
|
end
|
|
85
206
|
end
|
|
86
207
|
|
|
87
|
-
|
|
88
|
-
|
|
208
|
+
def worktree_review(dir, st, max_lines:, max_total_lines: DEFAULT_MAX_TOTAL_LINES)
|
|
209
|
+
chunks = split_diff(git(dir, "diff", "HEAD")[0])
|
|
210
|
+
entries_review(st[:files], max_lines: max_lines, max_total_lines: max_total_lines) do |f|
|
|
211
|
+
f[:untracked] ? diff(dir, file: f[:path]) : (chunks[f[:path]] || diff(dir, file: f[:path]))
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def unstaged_review(dir, st, max_lines:, max_total_lines: DEFAULT_MAX_TOTAL_LINES)
|
|
216
|
+
chunks = split_diff(git(dir, "diff")[0])
|
|
217
|
+
subset = st[:files].select { |f| f[:untracked] || f[:y].to_s != "." }
|
|
218
|
+
entries_review(subset, max_lines: max_lines, max_total_lines: max_total_lines) do |f|
|
|
219
|
+
f[:untracked] ? diff(dir, file: f[:path]) : chunks[f[:path]].to_s
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def range_review(dir, range_args, max_lines:, max_total_lines: DEFAULT_MAX_TOTAL_LINES)
|
|
224
|
+
chunks = split_diff(git(dir, "diff", *range_args)[0])
|
|
225
|
+
files = chunks.map do |path, text|
|
|
226
|
+
build_file(path, text, max_lines: max_lines,
|
|
227
|
+
x: diff_letter(text), y: ".")
|
|
228
|
+
end
|
|
229
|
+
totals(budget_files(files, max_total_lines))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def entries_review(entries, max_lines:, max_total_lines: DEFAULT_MAX_TOTAL_LINES)
|
|
233
|
+
files = entries.map do |f|
|
|
234
|
+
text = yield(f).to_s
|
|
235
|
+
build_file(f[:path], text, max_lines: max_lines,
|
|
236
|
+
x: f[:x], y: f[:y], untracked: f[:untracked])
|
|
237
|
+
end
|
|
238
|
+
totals(budget_files(files, max_total_lines))
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A branch comparison can span hundreds of files and hundreds of
|
|
242
|
+
# thousands of lines. Shipping every diff as JSON freezes the browser
|
|
243
|
+
# while it builds that much DOM, so files past the budget lose their
|
|
244
|
+
# `diff` payload and are flagged `lazy` — the UI fetches them one at a
|
|
245
|
+
# time when the user actually opens one. Counts stay intact so the tree
|
|
246
|
+
# still shows accurate +/- badges.
|
|
247
|
+
def budget_files(files, max_total_lines)
|
|
248
|
+
remaining = max_total_lines.to_i
|
|
249
|
+
files.each do |f|
|
|
250
|
+
if remaining <= 0
|
|
251
|
+
f[:lazy] = true
|
|
252
|
+
f[:diff] = ""
|
|
253
|
+
else
|
|
254
|
+
remaining -= f[:diff].lines.size
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
files
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def build_file(path, text, max_lines:, x: "M", y: ".", untracked: false, binary: nil)
|
|
261
|
+
text, truncated = trim_diff(text.to_s, max_lines)
|
|
262
|
+
add, del = count_changes(text)
|
|
263
|
+
{ path: path, x: x, y: y, untracked: untracked,
|
|
264
|
+
add: add, del: del, truncated: truncated,
|
|
265
|
+
binary: binary.nil? ? text.match?(/^Binary files .* differ$/) : binary,
|
|
266
|
+
diff: text }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def totals(files)
|
|
270
|
+
{ files: files,
|
|
271
|
+
add: files.sum { |f| f[:add] },
|
|
272
|
+
del: files.sum { |f| f[:del] } }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# A file added or removed between the two sides of a diff has no content
|
|
276
|
+
# on one of them; the UI uses the letter for its A/D/M tag.
|
|
277
|
+
def diff_letter(text)
|
|
278
|
+
return "A" if text.match?(/^new file mode /)
|
|
279
|
+
return "D" if text.match?(/^deleted file mode /)
|
|
280
|
+
"M"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Upstream of the current branch is the natural base for "committed":
|
|
284
|
+
# it is exactly the commits that have not been pushed yet. Falling back
|
|
285
|
+
# to origin/HEAD and then a local main/master covers fresh clones and
|
|
286
|
+
# branches without an upstream.
|
|
287
|
+
def default_base(dir)
|
|
288
|
+
out, _err, ok = git(dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
|
|
289
|
+
return out.strip if ok && !out.strip.empty?
|
|
290
|
+
|
|
291
|
+
out, _err, ok = git(dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
|
|
292
|
+
return out.strip if ok && !out.strip.empty?
|
|
293
|
+
|
|
294
|
+
%w[origin/main origin/master main master].find { |name| ref?(dir, name) }.to_s
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def ref?(dir, name)
|
|
298
|
+
return false if name.to_s.empty? || name.start_with?("-")
|
|
299
|
+
|
|
300
|
+
_out, _err, ok = git(dir, "rev-parse", "--verify", "--quiet", "#{name}^{commit}")
|
|
301
|
+
ok
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Scope "lastTurn": what the previous agent turn changed. Git has no
|
|
305
|
+
# notion of a turn, so this comes from Time Machine snapshots — each task
|
|
306
|
+
# keeps a BEFORE/AFTER tree of the files it touched.
|
|
307
|
+
def last_turn_review(agent, max_lines: 4000, max_total_lines: DEFAULT_MAX_TOTAL_LINES)
|
|
308
|
+
payload = { repo: true, scope: "lastTurn", files: [], add: 0, del: 0, note: "noTurn" }
|
|
309
|
+
return payload unless agent.respond_to?(:get_task_history)
|
|
310
|
+
|
|
311
|
+
history = agent.get_task_history(limit: 20)
|
|
312
|
+
current = history.find { |task| task[:status] == :current }
|
|
313
|
+
task_id = current ? current[:parent_task_id].to_i : 0
|
|
314
|
+
return payload if task_id <= 0
|
|
315
|
+
|
|
316
|
+
files = agent.task_diff_files(task_id).map do |entry|
|
|
317
|
+
detail = agent.task_file_diff(task_id, entry[:path]) || {}
|
|
318
|
+
build_file(entry[:path], detail[:patch].to_s, max_lines: max_lines,
|
|
319
|
+
x: { "added" => "A", "deleted" => "D" }.fetch(entry[:status], "M"),
|
|
320
|
+
y: ".", binary: detail[:binary] ? true : nil)
|
|
321
|
+
end
|
|
322
|
+
totals(budget_files(files, max_total_lines)).merge(repo: true, scope: "lastTurn", task_id: task_id)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Recent commits: [{ hash:, short:, author:, date:, subject: }]. With
|
|
326
|
+
# `unpushed: true` the log is limited to the commits the default base
|
|
327
|
+
# does not contain yet — the same range the "committed" scope diffs, so
|
|
328
|
+
# the menu never offers a commit that is already on the remote.
|
|
329
|
+
def log(dir, limit: 50, unpushed: false)
|
|
89
330
|
limit = limit.to_i.clamp(1, 200)
|
|
90
331
|
fmt = "%H%x1f%h%x1f%an%x1f%ad%x1f%s"
|
|
91
|
-
|
|
332
|
+
args = ["log", "-n", limit.to_s, "--date=short", "--pretty=format:#{fmt}"]
|
|
333
|
+
if unpushed
|
|
334
|
+
base = default_base(dir)
|
|
335
|
+
return [] unless ref?(dir, base)
|
|
336
|
+
|
|
337
|
+
args << "#{base}..HEAD"
|
|
338
|
+
end
|
|
339
|
+
out, _err, ok = git(dir, *args)
|
|
92
340
|
return [] unless ok
|
|
93
341
|
|
|
94
342
|
out.each_line.filter_map do |line|
|
|
@@ -98,18 +346,66 @@ module Clacky
|
|
|
98
346
|
end
|
|
99
347
|
end
|
|
100
348
|
|
|
101
|
-
# [{ name:, current: bool }] from `git branch`.
|
|
349
|
+
# [{ name:, current: bool, remote: bool }] from `git branch --all`.
|
|
350
|
+
# Remote HEAD pointers (`origin/HEAD` → `origin/main`) are dropped: they
|
|
351
|
+
# are aliases, not branches the user can compare against.
|
|
102
352
|
def branches(dir)
|
|
103
|
-
out, _err, ok = git(dir, "branch", "--
|
|
353
|
+
out, _err, ok = git(dir, "branch", "--all",
|
|
354
|
+
"--format=%(refname)%00%(refname:short)%00%(HEAD)")
|
|
104
355
|
return [] unless ok
|
|
105
356
|
|
|
106
357
|
out.each_line.filter_map do |line|
|
|
107
|
-
name, head = line.chomp.split("\x00")
|
|
108
|
-
next unless name && !name.empty?
|
|
109
|
-
|
|
358
|
+
full, name, head = line.chomp.split("\x00")
|
|
359
|
+
next unless full && name && !name.empty?
|
|
360
|
+
next if full.end_with?("/HEAD")
|
|
361
|
+
{ name: name, current: head == "*", remote: full.start_with?("refs/remotes/") }
|
|
110
362
|
end
|
|
111
363
|
end
|
|
112
364
|
|
|
365
|
+
# Current branch name, or nil when HEAD is detached / the dir is not a repo.
|
|
366
|
+
def branch_name(dir)
|
|
367
|
+
out, _err, ok = git(dir, "symbolic-ref", "--short", "-q", "HEAD")
|
|
368
|
+
ok ? out.strip : nil
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Whether `name` is a branch (or ref) git would accept. Refusing a leading
|
|
372
|
+
# dash keeps the value from being parsed as an option by the argv call
|
|
373
|
+
# below; the rest of the rules come from git itself so aliases such as
|
|
374
|
+
# `origin/pr-685` stay usable.
|
|
375
|
+
#
|
|
376
|
+
# Returns true/false - never raises.
|
|
377
|
+
def branch_ref?(dir, name)
|
|
378
|
+
ref = name.to_s.strip
|
|
379
|
+
return false if ref.empty? || ref.start_with?("-")
|
|
380
|
+
|
|
381
|
+
_out, _err, ok = git(dir, "check-ref-format", "--branch", ref)
|
|
382
|
+
ok
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Switch the work tree to `branch`, optionally creating it (from `from`,
|
|
386
|
+
# or from HEAD when omitted). Uncommitted changes are left to git: it
|
|
387
|
+
# either carries them across or refuses with a message the UI shows as-is.
|
|
388
|
+
def checkout(dir, branch:, create: false, from: nil)
|
|
389
|
+
name = branch.to_s.strip
|
|
390
|
+
return { ok: false, error: "branch is required" } if name.empty?
|
|
391
|
+
return { ok: false, error: "invalid branch name" } unless branch_ref?(dir, name)
|
|
392
|
+
|
|
393
|
+
args = ["checkout"]
|
|
394
|
+
if create
|
|
395
|
+
start = from.to_s.strip
|
|
396
|
+
return { ok: false, error: "invalid base ref" } unless start.empty? || branch_ref?(dir, start)
|
|
397
|
+
args.push("-b", name)
|
|
398
|
+
args.push(start) unless start.empty?
|
|
399
|
+
else
|
|
400
|
+
args.push(name)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
_out, err, ok = git(dir, *args)
|
|
404
|
+
return { ok: true, branch: name } if ok
|
|
405
|
+
|
|
406
|
+
{ ok: false, error: err.strip.empty? ? "git checkout failed" : err.strip }
|
|
407
|
+
end
|
|
408
|
+
|
|
113
409
|
# Discard uncommitted changes to one file and return it to its HEAD
|
|
114
410
|
# state (same baseline the diff API uses, so "what you saw is what you
|
|
115
411
|
# lose"). Files that never existed in HEAD (staged-new or untracked)
|
|
@@ -147,28 +443,58 @@ module Clacky
|
|
|
147
443
|
end
|
|
148
444
|
end
|
|
149
445
|
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
446
|
+
# Split a multi-file `git diff` into { path => chunk } keyed by the repo
|
|
447
|
+
# path, preferring the `+++ b/` side (the `--- a/` side is
|
|
448
|
+
# `/dev/null` for added files).
|
|
449
|
+
def split_diff(text)
|
|
450
|
+
chunks = {}
|
|
451
|
+
path = nil
|
|
452
|
+
buf = []
|
|
453
|
+
flush = lambda do
|
|
454
|
+
chunks[path] = buf.join if path && !buf.empty?
|
|
455
|
+
end
|
|
456
|
+
text.each_line do |line|
|
|
457
|
+
if line.start_with?("diff --git ")
|
|
458
|
+
flush.call
|
|
459
|
+
buf = [line]
|
|
460
|
+
path = nil
|
|
461
|
+
else
|
|
462
|
+
buf << line
|
|
463
|
+
next if path
|
|
464
|
+
if line.start_with?("--- a/")
|
|
465
|
+
path = line.sub("--- a/", "").chomp
|
|
466
|
+
elsif line.start_with?("+++ b/")
|
|
467
|
+
path = line.sub("+++ b/", "").chomp
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
flush.call
|
|
472
|
+
chunks
|
|
153
473
|
end
|
|
154
474
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
475
|
+
def count_changes(text)
|
|
476
|
+
add = 0
|
|
477
|
+
del = 0
|
|
478
|
+
text.each_line do |line|
|
|
479
|
+
next if line.start_with?("+++", "---")
|
|
480
|
+
if line.start_with?("+")
|
|
481
|
+
add += 1
|
|
482
|
+
elsif line.start_with?("-")
|
|
483
|
+
del += 1
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
[add, del]
|
|
487
|
+
end
|
|
166
488
|
|
|
167
|
-
|
|
168
|
-
|
|
489
|
+
def trim_diff(text, max_lines)
|
|
490
|
+
lines = text.lines
|
|
491
|
+
return [text, false] if lines.size <= max_lines
|
|
492
|
+
[lines.first(max_lines).join, true]
|
|
493
|
+
end
|
|
169
494
|
|
|
170
|
-
|
|
171
|
-
|
|
495
|
+
# Relative repo paths only: no absolute paths, no traversal segments.
|
|
496
|
+
def invalid_path?(path)
|
|
497
|
+
path.start_with?("/") || path.split("/").include?("..")
|
|
172
498
|
end
|
|
173
499
|
end
|
|
174
500
|
end
|