Module: Clacky::Server::GitPanel

Defined in:
lib/clacky/server/git_panel.rb

Overview

Read-mostly git operations scoped to a session’s working directory, backing the official “git” WebUI panel. Commands run with explicit argv (no shell), so user-supplied values (paths, messages) cannot inject. Write operations are limited to a guarded ‘commit`; history-rewriting / remote-mutating commands are never exposed here.

Class Method Summary collapse

Class Method Details

.branches(dir) ⇒ Object

{ name:, current: bool }

from ‘git branch`.



84
85
86
87
88
89
90
91
92
93
# File 'lib/clacky/server/git_panel.rb', line 84

def branches(dir)
  out, _err, ok = git(dir, "branch", "--format=%(refname:short)%00%(HEAD)")
  return [] unless ok

  out.each_line.filter_map do |line|
    name, head = line.chomp.split("\x00")
    next unless name && !name.empty?
    { name: name, current: head == "*" }
  end
end

.commit(dir, message:, files:) ⇒ Object

Stage ‘files` (relative paths) and commit with `message`. Returns { ok:, error?:, hash? }. Refuses empty message / empty file set. Uses argv so paths/message cannot inject; no –no-verify, no amend.



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/clacky/server/git_panel.rb', line 98

def commit(dir, message:, files:)
  msg   = message.to_s.strip
  paths = Array(files).map(&:to_s).reject(&:empty?)
  return { ok: false, error: "commit message is required" } if msg.empty?
  return { ok: false, error: "no files selected" } if paths.empty?

  _out, add_err, add_ok = git(dir, "add", "--", *paths)
  return { ok: false, error: "git add failed: #{add_err.strip}" } unless add_ok

  _out, c_err, c_ok = git(dir, "commit", "-m", msg, "--", *paths)
  return { ok: false, error: "git commit failed: #{c_err.strip}" } unless c_ok

  head, _err, _ok = git(dir, "rev-parse", "--short", "HEAD")
  { ok: true, hash: head.strip }
end

.diff(dir, file: nil) ⇒ Object

Unified diff. ‘file` (optional, relative) limits to one path; omitted = whole working tree (tracked changes). `–` guards path from being read as an option.



62
63
64
65
66
67
# File 'lib/clacky/server/git_panel.rb', line 62

def diff(dir, file: nil)
  args = ["diff"]
  args += ["--", file] if file && !file.empty?
  out, _err, _ok = git(dir, *args)
  out
end

.git(dir, *args) ⇒ Object

Run a git subcommand in ‘dir` with argv-style args (no shell). Returns [stdout, stderr, success_bool]. Never raises on git failure.



17
18
19
20
21
22
# File 'lib/clacky/server/git_panel.rb', line 17

def git(dir, *args)
  out, err, status = Open3.capture3("git", "-C", dir.to_s, *args)
  [out, err, status.success?]
rescue StandardError => e
  ["", e.message, false]
end

.log(dir, limit: 50) ⇒ Object

Recent commits: [{ hash:, short:, author:, date:, subject: }].



70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/clacky/server/git_panel.rb', line 70

def log(dir, limit: 50)
  limit = limit.to_i.clamp(1, 200)
  fmt = "%H%x1f%h%x1f%an%x1f%ad%x1f%s"
  out, _err, ok = git(dir, "log", "-n", limit.to_s, "--date=short", "--pretty=format:#{fmt}")
  return [] unless ok

  out.each_line.filter_map do |line|
    h, short, author, date, subject = line.chomp.split("\x1f")
    next unless h
    { hash: h, short: short, author: author, date: date, subject: subject }
  end
end

.repo?(dir) ⇒ Boolean

Whether ‘dir` is inside a git work tree.

Returns:

  • (Boolean)


25
26
27
28
# File 'lib/clacky/server/git_panel.rb', line 25

def repo?(dir)
  out, _err, ok = git(dir, "rev-parse", "--is-inside-work-tree")
  ok && out.strip == "true"
end

.status(dir) ⇒ Object

{ branch:, ahead:, behind:, files: [{ path:, x:, y:, staged:, untracked: }] } Parsed from ‘git status –porcelain=v2 –branch`.



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/clacky/server/git_panel.rb', line 32

def status(dir)
  out, _err, ok = git(dir, "status", "--porcelain=v2", "--branch")
  return { branch: nil, files: [] } unless ok

  branch = nil
  ahead = behind = 0
  files = []
  out.each_line do |line|
    line = line.chomp
    if line.start_with?("# branch.head ")
      branch = line.sub("# branch.head ", "")
    elsif line.start_with?("# branch.ab ")
      m = line.match(/\+(\d+) -(\d+)/)
      ahead, behind = m[1].to_i, m[2].to_i if m
    elsif line.start_with?("1 ", "2 ")
      xy = line.split(" ")[1]
      path = line.split(" ", 9).last
      files << { path: path, x: xy[0], y: xy[1],
                 staged: xy[0] != ".", untracked: false }
    elsif line.start_with?("? ")
      files << { path: line.sub("? ", ""), x: "?", y: "?",
                 staged: false, untracked: true }
    end
  end
  { branch: branch, ahead: ahead, behind: behind, files: files }
end