From d3fc3221ec989ba9467ebcf639cb71d61e419f49 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Thu, 30 Apr 2026 09:19:16 +0000 Subject: [PATCH 1/7] feat(git): add git.run shell-out wrapper --- src/server/src/fbi/git.gleam | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/server/src/fbi/git.gleam diff --git a/src/server/src/fbi/git.gleam b/src/server/src/fbi/git.gleam new file mode 100644 index 0000000..fac966b --- /dev/null +++ b/src/server/src/fbi/git.gleam @@ -0,0 +1,53 @@ +import gleam/list + +pub type GitError { + ExitNonZero(exit_code: Int, output: String) + GitUnavailable +} + +/// Shell out to `git -C repo_path `. Returns combined stdout+stderr +/// on exit 0, ExitNonZero with the same on any other exit, or GitUnavailable +/// if the git binary can't be found in PATH. +pub fn run(repo_path: String, args: List(String)) -> Result(String, GitError) { + case resolved_git() { + Error(_) -> Error(GitUnavailable) + Ok(git_path) -> { + let full_args = list.append(["-C", repo_path], args) + let #(code, output) = fbi_cmd_run(git_path, full_args, []) + case code { + 0 -> Ok(output) + _ -> Error(ExitNonZero(code, output)) + } + } + } +} + +pub fn describe_error(e: GitError) -> String { + case e { + ExitNonZero(code, output) -> + "git exit " <> int_to_string(code) <> ": " <> output + GitUnavailable -> "git not available on PATH" + } +} + +fn resolved_git() -> Result(String, Nil) { + let resolved = fbi_cmd_find_executable("git") + // fbi_cmd:find_executable returns the input unchanged when not found. + case resolved { + "git" -> Error(Nil) + p -> Ok(p) + } +} + +@external(erlang, "fbi_cmd", "run") +fn fbi_cmd_run( + cmd: String, + args: List(String), + env: List(#(String, String)), +) -> #(Int, String) + +@external(erlang, "fbi_cmd", "find_executable") +fn fbi_cmd_find_executable(name: String) -> String + +@external(erlang, "erlang", "integer_to_binary") +fn int_to_string(i: Int) -> String From 0af5a8f3decdb241a87419cf1badabc3c61b560a Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Thu, 30 Apr 2026 09:20:35 +0000 Subject: [PATCH 2/7] feat(git): add parse_log_porcelain, parse_name_status, parse_numstat, parse_diff_hunks, parse_status_porcelain_v2 --- src/server/src/fbi/git/parse.gleam | 233 +++++++++++++++++++++++ src/server/test/fbi/git/parse_test.gleam | 133 +++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 src/server/src/fbi/git/parse.gleam create mode 100644 src/server/test/fbi/git/parse_test.gleam diff --git a/src/server/src/fbi/git/parse.gleam b/src/server/src/fbi/git/parse.gleam new file mode 100644 index 0000000..049fb53 --- /dev/null +++ b/src/server/src/fbi/git/parse.gleam @@ -0,0 +1,233 @@ +import gleam/int +import gleam/list +import gleam/option +import gleam/result +import gleam/string + +pub type LogEntry { + LogEntry(sha: String, subject: String, committed_at: Int) +} + +pub fn parse_log_porcelain(s: String) -> List(LogEntry) { + string.split(s, "\n") + |> list.filter_map(fn(line) { + case line { + "" -> Error(Nil) + _ -> parse_log_line(line) + } + }) +} + +fn parse_log_line(line: String) -> Result(LogEntry, Nil) { + case string.split(line, "\u{0000}") { + [sha, subject, ts_str] -> { + use ts <- result.try(int.parse(ts_str)) + Ok(LogEntry(sha: sha, subject: subject, committed_at: ts)) + } + _ -> Error(Nil) + } +} + +pub type NameStatus { + NameStatus(status: String, path: String) +} + +pub type NumStat { + NumStat(additions: Int, deletions: Int, path: String) +} + +pub fn parse_name_status(s: String) -> List(NameStatus) { + string.split(s, "\n") + |> list.filter_map(fn(line) { + case line { + "" -> Error(Nil) + _ -> parse_name_status_line(line) + } + }) +} + +fn parse_name_status_line(line: String) -> Result(NameStatus, Nil) { + case string.split(line, "\t") { + [code, path] -> Ok(NameStatus(status: status_letter(code), path: path)) + [code, _old, new] -> Ok(NameStatus(status: status_letter(code), path: new)) + _ -> Error(Nil) + } +} + +fn status_letter(code: String) -> String { + case string.first(code) { + Ok(c) -> c + Error(_) -> code + } +} + +pub fn parse_numstat(s: String) -> List(NumStat) { + string.split(s, "\n") + |> list.filter_map(fn(line) { + case line { + "" -> Error(Nil) + _ -> parse_numstat_line(line) + } + }) +} + +fn parse_numstat_line(line: String) -> Result(NumStat, Nil) { + case string.split(line, "\t") { + [a, d, path] -> + Ok(NumStat( + additions: int.parse(a) |> result.unwrap(0), + deletions: int.parse(d) |> result.unwrap(0), + path: path, + )) + _ -> Error(Nil) + } +} + +pub type Line { + Line(kind: String, text: String) +} + +pub type Hunk { + Hunk(header: String, lines: List(Line)) +} + +pub fn parse_diff_hunks(s: String) -> List(Hunk) { + let lines = string.split(s, "\n") + do_parse_hunks(lines, option.None, [], []) + |> list.reverse +} + +fn do_parse_hunks( + remaining: List(String), + current_header: option.Option(String), + current_lines: List(Line), + acc: List(Hunk), +) -> List(Hunk) { + case remaining { + [] -> + case current_header { + option.Some(h) -> [ + Hunk(header: h, lines: list.reverse(current_lines)), + ..acc + ] + option.None -> acc + } + [line, ..rest] -> + case classify_line(line) { + HunkHeader(h) -> { + let acc2 = case current_header { + option.Some(prev) -> [ + Hunk(header: prev, lines: list.reverse(current_lines)), + ..acc + ] + option.None -> acc + } + do_parse_hunks(rest, option.Some(h), [], acc2) + } + HunkLine(l) -> + do_parse_hunks(rest, current_header, [l, ..current_lines], acc) + Skip -> do_parse_hunks(rest, current_header, current_lines, acc) + } + } +} + +type LineKind { + HunkHeader(String) + HunkLine(Line) + Skip +} + +fn classify_line(line: String) -> LineKind { + case string.starts_with(line, "@@ ") { + True -> HunkHeader(line) + False -> + case line { + "" -> Skip + _ -> + case string.first(line) { + Ok(" ") -> HunkLine(Line(kind: "ctx", text: drop_first(line))) + Ok("+") -> { + case string.starts_with(line, "+++ ") { + True -> Skip + False -> HunkLine(Line(kind: "add", text: drop_first(line))) + } + } + Ok("-") -> { + case string.starts_with(line, "--- ") { + True -> Skip + False -> HunkLine(Line(kind: "del", text: drop_first(line))) + } + } + _ -> Skip + } + } + } +} + +fn drop_first(s: String) -> String { + string.drop_start(s, 1) +} + +pub type StatusEntry { + StatusEntry(status: String, path: String) +} + +pub fn parse_status_porcelain_v2(s: String) -> List(StatusEntry) { + string.split(s, "\u{0000}") + |> list.filter_map(parse_status_record) +} + +fn parse_status_record(rec: String) -> Result(StatusEntry, Nil) { + case rec { + "" -> Error(Nil) + _ -> + case string.first(rec) { + Ok("?") -> + Ok(StatusEntry(status: "U", path: string.drop_start(rec, 1))) + Ok("1") -> parse_v2_ordinary(rec) + Ok("2") -> parse_v2_rename(rec) + Ok("u") -> parse_v2_unmerged(rec) + _ -> Error(Nil) + } + } +} + +fn parse_v2_ordinary(rec: String) -> Result(StatusEntry, Nil) { + case string.split(rec, " ") { + ["1", xy, ..rest] -> { + let path = string.join(list.drop(rest, 6), " ") + Ok(StatusEntry(status: status_xy(xy), path: path)) + } + _ -> Error(Nil) + } +} + +fn parse_v2_rename(rec: String) -> Result(StatusEntry, Nil) { + case string.split(rec, " ") { + ["2", xy, ..rest] -> { + let path = string.join(list.drop(rest, 7), " ") + Ok(StatusEntry(status: status_xy(xy), path: path)) + } + _ -> Error(Nil) + } +} + +fn parse_v2_unmerged(rec: String) -> Result(StatusEntry, Nil) { + case string.split(rec, " ") { + ["u", xy, ..rest] -> { + let path = string.join(list.drop(rest, 7), " ") + Ok(StatusEntry(status: status_xy(xy), path: path)) + } + _ -> Error(Nil) + } +} + +/// XY is two characters: index status + worktree status. Take the first +/// non-`.` character as the effective status; default to the worktree slot. +fn status_xy(xy: String) -> String { + case string.to_graphemes(xy) { + [".", w, ..] -> w + [i, _, ..] -> i + _ -> "?" + } +} diff --git a/src/server/test/fbi/git/parse_test.gleam b/src/server/test/fbi/git/parse_test.gleam new file mode 100644 index 0000000..815cf96 --- /dev/null +++ b/src/server/test/fbi/git/parse_test.gleam @@ -0,0 +1,133 @@ +import fbi/git/parse +import gleam/list +import gleeunit/should + +pub fn parse_log_porcelain_two_commits_test() { + let input = + "abc123\u{0000}first commit\u{0000}1700000001\nde4567\u{0000}second commit\u{0000}1700000002\n" + let parsed = parse.parse_log_porcelain(input) + parsed + |> should.equal([ + parse.LogEntry(sha: "abc123", subject: "first commit", committed_at: 1_700_000_001), + parse.LogEntry( + sha: "de4567", + subject: "second commit", + committed_at: 1_700_000_002, + ), + ]) +} + +pub fn parse_log_porcelain_empty_test() { + parse.parse_log_porcelain("") |> should.equal([]) +} + +pub fn parse_log_porcelain_handles_nul_in_subject_test() { + let input = "abc\u{0000}only-two-fields\nde4\u{0000}good\u{0000}1700000003\n" + let parsed = parse.parse_log_porcelain(input) + parsed + |> should.equal([ + parse.LogEntry(sha: "de4", subject: "good", committed_at: 1_700_000_003), + ]) +} + +pub fn parse_name_status_simple_test() { + let input = "M\tsrc/foo.gleam\nA\tsrc/bar.gleam\nD\tsrc/baz.gleam\n" + parse.parse_name_status(input) + |> should.equal([ + parse.NameStatus(status: "M", path: "src/foo.gleam"), + parse.NameStatus(status: "A", path: "src/bar.gleam"), + parse.NameStatus(status: "D", path: "src/baz.gleam"), + ]) +} + +pub fn parse_name_status_rename_test() { + let input = "R100\told.txt\tnew.txt\n" + parse.parse_name_status(input) + |> should.equal([parse.NameStatus(status: "R", path: "new.txt")]) +} + +pub fn parse_numstat_simple_test() { + let input = "10\t2\tsrc/a.gleam\n0\t5\tsrc/b.gleam\n" + parse.parse_numstat(input) + |> should.equal([ + parse.NumStat(additions: 10, deletions: 2, path: "src/a.gleam"), + parse.NumStat(additions: 0, deletions: 5, path: "src/b.gleam"), + ]) +} + +pub fn parse_numstat_binary_dashes_test() { + let input = "-\t-\tlogo.png\n" + parse.parse_numstat(input) + |> should.equal([parse.NumStat(additions: 0, deletions: 0, path: "logo.png")]) +} + +pub fn parse_diff_hunks_one_hunk_test() { + let input = + "diff --git a/foo b/foo\n" + <> "--- a/foo\n" + <> "+++ b/foo\n" + <> "@@ -1,3 +1,3 @@\n" + <> " a\n" + <> "-b\n" + <> "+B\n" + <> " c\n" + let hunks = parse.parse_diff_hunks(input) + hunks + |> should.equal([ + parse.Hunk(header: "@@ -1,3 +1,3 @@", lines: [ + parse.Line(kind: "ctx", text: "a"), + parse.Line(kind: "del", text: "b"), + parse.Line(kind: "add", text: "B"), + parse.Line(kind: "ctx", text: "c"), + ]), + ]) +} + +pub fn parse_diff_hunks_multiple_hunks_test() { + let input = + "@@ -1 +1 @@\n" + <> "-a\n" + <> "+b\n" + <> "@@ -10 +10 @@ context tail\n" + <> "-c\n" + <> "+d\n" + let hunks = parse.parse_diff_hunks(input) + hunks + |> list.length + |> should.equal(2) +} + +pub fn parse_diff_hunks_no_newline_marker_test() { + let input = + "@@ -1 +1 @@\n" + <> "-a\n" + <> "\\ No newline at end of file\n" + <> "+b\n" + let hunks = parse.parse_diff_hunks(input) + let assert [hunk] = hunks + hunk.lines + |> should.equal([ + parse.Line(kind: "del", text: "a"), + parse.Line(kind: "add", text: "b"), + ]) +} + +pub fn parse_status_porcelain_v2_basic_test() { + let input = + "1 .M N... 100644 100644 100644 abc def src/foo.gleam\u{0000}" + <> "1 A. N... 000000 100644 100644 0000000 1234567 src/bar.gleam\u{0000}" + <> "?untracked.txt\u{0000}" + parse.parse_status_porcelain_v2(input) + |> should.equal([ + parse.StatusEntry(status: "M", path: "src/foo.gleam"), + parse.StatusEntry(status: "A", path: "src/bar.gleam"), + parse.StatusEntry(status: "U", path: "untracked.txt"), + ]) +} + +pub fn parse_status_porcelain_v2_rename_test() { + let input = + "2 R. N... 100644 100644 100644 abc def R100 new.txt\u{0000}old.txt\u{0000}" + parse.parse_status_porcelain_v2(input) + |> should.equal([parse.StatusEntry(status: "R", path: "new.txt")]) +} From dcbcbb25cdc2c5d87b94b0e2ce3eb1a11fc7fd72 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Thu, 30 Apr 2026 09:24:35 +0000 Subject: [PATCH 3/7] feat(git): add repo module - commits_on_branch, commit_files, file_diff, branch_base_ahead_behind, wip_files, wip_file_diff, wip_patch --- src/server/src/fbi/git/repo.gleam | 244 ++++++++++++++++++++++++ src/server/test/fbi/git/repo_test.gleam | 150 +++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 src/server/src/fbi/git/repo.gleam create mode 100644 src/server/test/fbi/git/repo_test.gleam diff --git a/src/server/src/fbi/git/repo.gleam b/src/server/src/fbi/git/repo.gleam new file mode 100644 index 0000000..c92abeb --- /dev/null +++ b/src/server/src/fbi/git/repo.gleam @@ -0,0 +1,244 @@ +import fbi/git.{type GitError} +import fbi/git/parse +import gleam/dict +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string + +pub type FileEntry { + FileEntry(path: String, status: String, additions: Int, deletions: Int) +} + +pub type BranchBase { + BranchBase(base: String, ahead: Int, behind: Int) +} + +pub type WipSnapshot { + WipSnapshot(snapshot_sha: String, parent_sha: String, files: List(FileEntry)) +} + +const empty_tree_sha = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + +const diff_byte_cap = 1_048_576 + +pub fn commits_on_branch( + repo_path: String, + branch: String, + base: String, +) -> Result(List(parse.LogEntry), GitError) { + use output <- result_map(git.run(repo_path, [ + "log", + base <> ".." <> branch, + "--pretty=format:%H%x00%s%x00%ct", + ])) + parse.parse_log_porcelain(output) +} + +pub fn commit_files( + repo_path: String, + sha: String, +) -> Result(List(FileEntry), GitError) { + use ns_output <- result_try(git.run(repo_path, [ + "show", + "--no-renames", + "--pretty=", + "--name-status", + sha, + ])) + use num_output <- result_try(git.run(repo_path, [ + "show", + "--no-renames", + "--pretty=", + "--numstat", + sha, + ])) + let names = parse.parse_name_status(ns_output) + let nums = parse.parse_numstat(num_output) + Ok(merge_names_and_nums(names, nums)) +} + +pub fn file_diff( + repo_path: String, + sha: String, + path: String, +) -> Result(#(List(parse.Hunk), Bool), GitError) { + let parent_arg = case has_parent(repo_path, sha) { + True -> sha <> "^" + False -> empty_tree_sha + } + use output <- result_try(git.run(repo_path, [ + "diff", + "--no-color", + parent_arg, + sha, + "--", + path, + ])) + let truncated = string.byte_size(output) > diff_byte_cap + let body = case truncated { + True -> string.slice(output, 0, diff_byte_cap) + False -> output + } + Ok(#(parse.parse_diff_hunks(body), truncated)) +} + +pub fn branch_base_ahead_behind( + repo_path: String, + branch: String, + default: String, +) -> Result(BranchBase, GitError) { + use ahead_str <- result_try(git.run(repo_path, [ + "rev-list", + "--count", + default <> ".." <> branch, + ])) + use behind_str <- result_try(git.run(repo_path, [ + "rev-list", + "--count", + branch <> ".." <> default, + ])) + Ok(BranchBase( + base: default, + ahead: parse_int_first_line(ahead_str), + behind: parse_int_first_line(behind_str), + )) +} + +pub fn wip_files(repo_path: String) -> Result(Option(WipSnapshot), GitError) { + case git.run(repo_path, ["rev-parse", "--verify", "refs/fbi/wip-snapshot"]) { + Error(_) -> Ok(None) + Ok(snapshot_str) -> { + let snapshot_sha = string.trim(snapshot_str) + use parent_str <- result_try(git.run(repo_path, [ + "rev-parse", + snapshot_sha <> "^", + ])) + let parent_sha = string.trim(parent_str) + use ns_output <- result_try(git.run(repo_path, [ + "diff", + "--no-renames", + "--name-status", + parent_sha, + snapshot_sha, + ])) + use num_output <- result_try(git.run(repo_path, [ + "diff", + "--no-renames", + "--numstat", + parent_sha, + snapshot_sha, + ])) + let files = + merge_names_and_nums( + parse.parse_name_status(ns_output), + parse.parse_numstat(num_output), + ) + case files { + [] -> Ok(None) + _ -> + Ok(Some(WipSnapshot( + snapshot_sha: snapshot_sha, + parent_sha: parent_sha, + files: files, + ))) + } + } + } +} + +pub fn wip_file_diff( + repo_path: String, + path: String, +) -> Result(Option(#(List(parse.Hunk), Bool, String, String)), GitError) { + case wip_files(repo_path) { + Ok(None) -> Ok(None) + Error(e) -> Error(e) + Ok(Some(snap)) -> { + use output <- result_try(git.run(repo_path, [ + "diff", + "--no-color", + snap.parent_sha, + snap.snapshot_sha, + "--", + path, + ])) + let truncated = string.byte_size(output) > diff_byte_cap + let body = case truncated { + True -> string.slice(output, 0, diff_byte_cap) + False -> output + } + Ok( + Some(#( + parse.parse_diff_hunks(body), + truncated, + snap.snapshot_sha, + snap.parent_sha, + )), + ) + } + } +} + +pub fn wip_patch(repo_path: String) -> Result(String, GitError) { + case wip_files(repo_path) { + Error(e) -> Error(e) + Ok(None) -> Ok("") + Ok(Some(snap)) -> + git.run(repo_path, ["diff", snap.parent_sha, snap.snapshot_sha]) + } +} + +fn merge_names_and_nums( + names: List(parse.NameStatus), + nums: List(parse.NumStat), +) -> List(FileEntry) { + let num_map = + list.fold(nums, dict.new(), fn(m, n) { dict.insert(m, n.path, n) }) + list.map(names, fn(ns) { + case dict.get(num_map, ns.path) { + Ok(n) -> + FileEntry( + path: ns.path, + status: ns.status, + additions: n.additions, + deletions: n.deletions, + ) + Error(_) -> + FileEntry(path: ns.path, status: ns.status, additions: 0, deletions: 0) + } + }) +} + +fn has_parent(repo_path: String, sha: String) -> Bool { + case git.run(repo_path, ["rev-parse", "--verify", sha <> "^"]) { + Ok(_) -> True + Error(_) -> False + } +} + +fn parse_int_first_line(s: String) -> Int { + s + |> string.trim + |> int.parse + |> fn(r) { + case r { + Ok(n) -> n + Error(_) -> 0 + } + } +} + +fn result_map(r: Result(a, e), f: fn(a) -> b) -> Result(b, e) { + case r { + Ok(v) -> Ok(f(v)) + Error(e) -> Error(e) + } +} + +fn result_try(r: Result(a, e), f: fn(a) -> Result(b, e)) -> Result(b, e) { + case r { + Ok(v) -> f(v) + Error(e) -> Error(e) + } +} diff --git a/src/server/test/fbi/git/repo_test.gleam b/src/server/test/fbi/git/repo_test.gleam new file mode 100644 index 0000000..72e22b1 --- /dev/null +++ b/src/server/test/fbi/git/repo_test.gleam @@ -0,0 +1,150 @@ +import fbi/git +import fbi/git/parse +import fbi/git/repo +import gleam/int +import gleam/list +import gleam/option +import gleam/string +import gleeunit/should +import simplifile + +fn seed_bare_repo() -> #(String, String) { + let tmp = + "/tmp/fbi-repo-test-" + <> int.to_string(now_ms()) + <> "-" + <> int.to_string(unique_int()) + let bare = tmp <> "/bare.git" + let work = tmp <> "/work" + let _ = simplifile.create_directory_all(bare) + let _ = simplifile.create_directory_all(work) + let assert Ok(_) = git.run(bare, ["init", "--bare"]) + let assert Ok(_) = git.run(work, ["init"]) + let assert Ok(_) = git.run(work, ["config", "user.email", "t@t"]) + let assert Ok(_) = git.run(work, ["config", "user.name", "t"]) + let assert Ok(_) = simplifile.write(work <> "/a.txt", "hello\n") + let assert Ok(_) = git.run(work, ["add", "a.txt"]) + let assert Ok(_) = git.run(work, ["commit", "-m", "initial"]) + let assert Ok(_) = git.run(work, ["branch", "-M", "main"]) + let assert Ok(_) = git.run(work, ["remote", "add", "bare", bare]) + let assert Ok(_) = git.run(work, ["push", "bare", "main"]) + let assert Ok(_) = + git.run(bare, ["symbolic-ref", "HEAD", "refs/heads/main"]) + let assert Ok(sha) = git.run(work, ["rev-parse", "HEAD"]) + let sha_trimmed = string.trim(sha) + #(bare, sha_trimmed) +} + +@external(erlang, "fbi_time", "now_ms") +fn now_ms() -> Int + +@external(erlang, "erlang", "unique_integer") +fn unique_int() -> Int + +pub fn commits_on_branch_returns_seeded_commit_test() { + let #(bare, sha) = seed_bare_repo() + let assert Ok(commits) = repo.commits_on_branch(bare, "main", "main") + commits |> should.equal([]) + let assert Ok(_) = git.run(bare, ["cat-file", "-p", sha]) +} + +pub fn commit_files_returns_file_entries_test() { + let #(bare, sha) = seed_bare_repo() + let assert Ok(files) = repo.commit_files(bare, sha) + files + |> should.equal([ + repo.FileEntry(path: "a.txt", status: "A", additions: 1, deletions: 0), + ]) +} + +pub fn file_diff_committed_sha_test() { + let #(bare, sha) = seed_bare_repo() + let assert Ok(#(hunks, truncated)) = repo.file_diff(bare, sha, "a.txt") + truncated |> should.equal(False) + hunks |> list.length |> should.equal(1) +} + +pub fn file_diff_unknown_sha_returns_error_test() { + let #(bare, _) = seed_bare_repo() + let assert Error(_) = repo.file_diff(bare, "deadbeefdeadbeef", "a.txt") +} + +pub fn branch_base_ahead_behind_test() { + let #(bare, _) = seed_bare_repo() + let work2 = bare <> "-work2" + let assert Ok(_) = simplifile.create_directory_all(work2) + let assert Ok(_) = git.run(work2, ["clone", bare, "."]) + let assert Ok(_) = git.run(work2, ["config", "user.email", "t@t"]) + let assert Ok(_) = git.run(work2, ["config", "user.name", "t"]) + let assert Ok(_) = git.run(work2, ["checkout", "-b", "feature"]) + let assert Ok(_) = simplifile.write(work2 <> "/b.txt", "world\n") + let assert Ok(_) = git.run(work2, ["add", "b.txt"]) + let assert Ok(_) = git.run(work2, ["commit", "-m", "feature commit"]) + let assert Ok(_) = git.run(work2, ["push", "origin", "feature"]) + let assert Ok(result) = + repo.branch_base_ahead_behind(bare, "feature", "main") + result.base |> should.equal("main") + result.ahead |> should.equal(1) + result.behind |> should.equal(0) +} + +pub fn wip_files_no_snapshot_returns_none_test() { + let #(bare, _) = seed_bare_repo() + repo.wip_files(bare) |> should.equal(Ok(option.None)) +} + +pub fn wip_files_with_snapshot_returns_diff_test() { + let #(bare, head_sha) = seed_bare_repo() + let work = bare <> "-snap" + let assert Ok(_) = simplifile.create_directory_all(work) + let assert Ok(_) = git.run(work, ["clone", bare, "."]) + let assert Ok(_) = git.run(work, ["config", "user.email", "t@t"]) + let assert Ok(_) = git.run(work, ["config", "user.name", "t"]) + let assert Ok(_) = simplifile.write(work <> "/a.txt", "hello\nworld\n") + let assert Ok(_) = git.run(work, ["add", "a.txt"]) + let assert Ok(tree_str) = git.run(work, ["write-tree"]) + let tree = string.trim(tree_str) + let assert Ok(commit_str) = + git.run(work, ["commit-tree", tree, "-p", head_sha, "-m", "wip snapshot"]) + let snapshot_sha = string.trim(commit_str) + let assert Ok(_) = + git.run(work, [ + "push", + "origin", + snapshot_sha <> ":refs/fbi/wip-snapshot", + ]) + let assert Ok(option.Some(snapshot)) = repo.wip_files(bare) + snapshot.parent_sha |> should.equal(head_sha) + snapshot.snapshot_sha |> should.equal(snapshot_sha) + snapshot.files |> list.length |> should.equal(1) +} + +pub fn wip_files_no_diff_returns_none_test() { + // When snapshot tree == HEAD tree, no changed files → wip_files returns None. + let #(bare, head_sha) = seed_bare_repo() + let work = bare <> "-nodiff" + let assert Ok(_) = simplifile.create_directory_all(work) + let assert Ok(_) = git.run(work, ["clone", bare, "."]) + let assert Ok(_) = git.run(work, ["config", "user.email", "t@t"]) + let assert Ok(_) = git.run(work, ["config", "user.name", "t"]) + // Create snapshot with the same tree as HEAD. + let assert Ok(tree_str) = git.run(work, ["rev-parse", head_sha <> "^{tree}"]) + let tree = string.trim(tree_str) + let assert Ok(commit_str) = + git.run(work, ["commit-tree", tree, "-p", head_sha, "-m", "wip snapshot"]) + let snapshot_sha = string.trim(commit_str) + let assert Ok(_) = + git.run(work, [ + "push", + "origin", + snapshot_sha <> ":refs/fbi/wip-snapshot", + ]) + repo.wip_files(bare) |> should.equal(Ok(option.None)) +} + +// Satisfy the unused import warning from parse +pub fn parse_log_entry_unused_import_test() { + let _: parse.LogEntry = + parse.LogEntry(sha: "x", subject: "y", committed_at: 0) + should.equal(True, True) +} From a3e8b2224450b77842a92f22e0dbdaff0e4262ae Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Thu, 30 Apr 2026 09:25:43 +0000 Subject: [PATCH 4/7] feat(run): wip-snapshotter daemon, polish-prompt.txt, FBI_KIND env var --- src/server/priv/static/polish-prompt.txt | 3 +++ src/server/priv/static/supervisor.sh | 17 +++++++++++++ src/server/priv/static/wip-snapshotter.sh | 29 +++++++++++++++++++++++ src/server/src/fbi/run/worker.gleam | 17 +++++++++++-- 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/server/priv/static/polish-prompt.txt create mode 100755 src/server/priv/static/wip-snapshotter.sh diff --git a/src/server/priv/static/polish-prompt.txt b/src/server/priv/static/polish-prompt.txt new file mode 100644 index 0000000..abc30a7 --- /dev/null +++ b/src/server/priv/static/polish-prompt.txt @@ -0,0 +1,3 @@ +Review the most recent commits on this branch and polish them: improve commit +messages, split or squash where it improves clarity, and ensure each commit +stands alone. Do not change behavior — refactor only the history. diff --git a/src/server/priv/static/supervisor.sh b/src/server/priv/static/supervisor.sh index 4036d4a..d5f13e0 100755 --- a/src/server/priv/static/supervisor.sh +++ b/src/server/priv/static/supervisor.sh @@ -43,6 +43,11 @@ _fbi_fatal() { printf '\033[31m✕\033[0m \033[31m%s\033[0m\n' "$*" >&2; } export SSH_AUTH_SOCK=/ssh-agent +# WIP snapshotter — periodic snapshot of the working tree to safeguard. +if [ -x /usr/local/bin/fbi-wip-snapshotter.sh ]; then + /usr/local/bin/fbi-wip-snapshotter.sh >/dev/null 2>&1 & +fi + # Take ownership of the bind-mounted state and safeguard dirs. These are # created on the host by the server process and bind-mounted in; their # host uid won't match agent (1001) so writes from supervisor.sh and the @@ -228,6 +233,18 @@ else printf '\n\n---\n\n' >> /tmp/prompt.txt fi done + case "${FBI_KIND:-work}" in + polish) + if [ -f /usr/local/share/fbi/polish-prompt.txt ]; then + cat /usr/local/share/fbi/polish-prompt.txt > /fbi/prompt.txt + fi + ;; + merge-conflict) + cat <<'EOF' > /fbi/prompt.txt +Resolve the merge conflicts in /workspace, then commit the resolution. +EOF + ;; + esac [ -f /fbi/prompt.txt ] || { _fbi_fatal "prompt.txt not found in /fbi"; exit 12; } cat /fbi/prompt.txt >> /tmp/prompt.txt touch /fbi-state/prompted diff --git a/src/server/priv/static/wip-snapshotter.sh b/src/server/priv/static/wip-snapshotter.sh new file mode 100755 index 0000000..d1c2791 --- /dev/null +++ b/src/server/priv/static/wip-snapshotter.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# /usr/local/bin/fbi-wip-snapshotter.sh — runs as agent inside FBI containers. +# Every 5s, snapshot the working tree as a synthetic git commit and push it +# to refs/fbi/wip-snapshot on the safeguard remote. Never touches HEAD, +# branch refs, or the agent's index. + +set +e # never exit; transient git errors during agent ops are normal + +WORKTREE="${WORKTREE:-/workspace}" +INTERVAL="${WIP_SNAPSHOT_INTERVAL:-5}" + +cd "$WORKTREE" 2>/dev/null || exit 0 + +while true; do + sleep "$INTERVAL" + # Skip if HEAD doesn't resolve yet (pre-first-commit). + HEAD_SHA=$(git rev-parse HEAD 2>/dev/null) || continue + TMP_INDEX=$(mktemp /tmp/fbi-wip-index.XXXXXX) + cp .git/index "$TMP_INDEX" 2>/dev/null || continue + GIT_INDEX_FILE="$TMP_INDEX" git add -A 2>/dev/null + TREE=$(GIT_INDEX_FILE="$TMP_INDEX" git write-tree 2>/dev/null) + rm -f "$TMP_INDEX" + [ -z "$TREE" ] && continue + COMMIT=$(git commit-tree "$TREE" -p "$HEAD_SHA" -m "wip snapshot" 2>/dev/null) + [ -z "$COMMIT" ] && continue + git update-ref refs/fbi/wip-snapshot "$COMMIT" 2>/dev/null + git push --quiet --force safeguard \ + "refs/fbi/wip-snapshot:refs/fbi/wip-snapshot" 2>/dev/null +done diff --git a/src/server/src/fbi/run/worker.gleam b/src/server/src/fbi/run/worker.gleam index 82797f4..7a445da 100644 --- a/src/server/src/fbi/run/worker.gleam +++ b/src/server/src/fbi/run/worker.gleam @@ -265,6 +265,7 @@ fn build_env(input: LaunchInput) -> List(String) { input.config.git_author_email, ), "FBI_BRANCH=" <> input.run.branch_name, + "FBI_KIND=" <> input.run.kind, "IS_SANDBOX=1", ] let with_model = list.append(base, model_env(input.run)) @@ -312,6 +313,10 @@ fn build_binds(input: LaunchInput) -> List(String) { run_dir <> "/scripts/finalizeBranch.sh:/usr/local/bin/fbi-finalize-branch.sh:ro", run_dir <> "/scripts/fbi-history-op.sh:/usr/local/bin/fbi-history-op.sh:ro", + run_dir + <> "/scripts/wip-snapshotter.sh:/usr/local/bin/fbi-wip-snapshotter.sh:ro", + run_dir + <> "/scripts/polish-prompt.txt:/usr/local/share/fbi/polish-prompt.txt:ro", run_dir <> "/wip:/safeguard:rw", run_dir <> "/state:/fbi-state:rw", run_dir <> "/mount:/home/agent/.claude/projects/:rw", @@ -433,10 +438,18 @@ fn setup_run_dir(input: LaunchInput) -> Result(Nil, String) { fbi_priv_path("static/finalizeBranch.sh"), scripts_dir <> "/finalizeBranch.sh", )) - copy_script( + use _ <- result.try(copy_script( fbi_priv_path("static/fbi-history-op.sh"), scripts_dir <> "/fbi-history-op.sh", - ) + )) + use _ <- result.try(copy_script( + fbi_priv_path("static/wip-snapshotter.sh"), + scripts_dir <> "/wip-snapshotter.sh", + )) + let polish_src = fbi_priv_path("static/polish-prompt.txt") + let polish_dst = scripts_dir <> "/polish-prompt.txt" + let _ = simplifile.copy_file(polish_src, polish_dst) + Ok(Nil) } @external(erlang, "fbi_priv", "path") From 0997553f9b71c2b298585ae333f8982430dab2f8 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Thu, 30 Apr 2026 09:31:38 +0000 Subject: [PATCH 5/7] feat(api): real git introspection handlers, mutex actor, history ops, exec_container --- src/server/src/fbi.gleam | 10 +- src/server/src/fbi/context.gleam | 2 + src/server/src/fbi/db/runs.gleam | 94 +++++++++ src/server/src/fbi/docker.gleam | 69 +++++++ src/server/src/fbi/git/history_ops.gleam | 234 +++++++++++++++++++++ src/server/src/fbi/git/mutex.gleam | 46 +++++ src/server/src/fbi/handlers/changes.gleam | 240 +++++++++++++++++++--- src/server/src/fbi/handlers/history.gleam | 224 ++++++++++++++++++++ src/server/src/fbi/handlers/wip.gleam | 213 ++++++++++++++++--- src/server/src/fbi/router.gleam | 3 +- 10 files changed, 1073 insertions(+), 62 deletions(-) create mode 100644 src/server/src/fbi/git/history_ops.gleam create mode 100644 src/server/src/fbi/git/mutex.gleam create mode 100644 src/server/src/fbi/handlers/history.gleam diff --git a/src/server/src/fbi.gleam b/src/server/src/fbi.gleam index a6d41a5..af8e243 100644 --- a/src/server/src/fbi.gleam +++ b/src/server/src/fbi.gleam @@ -6,6 +6,7 @@ import fbi/db/migrations import fbi/handlers/shell_ws import fbi/handlers/states_ws import fbi/handlers/usage_ws +import fbi/git/mutex as history_mutex import fbi/pubsub import fbi/router import fbi/run/gc_scheduler @@ -53,11 +54,18 @@ pub fn main() { let assert Ok(registry) = run_registry.start() let assert Ok(pubsub_subject) = pubsub.start() + let assert Ok(history_lock) = history_mutex.start() reattach.run_all(db, cfg, registry) let assert Ok(_gc_scheduler) = gc_scheduler.start(db, cfg) let assert Ok(_resume_scheduler) = resume_scheduler.start(db, cfg, registry) let ctx = - Context(db: db, config: cfg, run_registry: registry, pubsub: pubsub_subject) + Context( + db: db, + config: cfg, + run_registry: registry, + pubsub: pubsub_subject, + history_mutex: history_lock, + ) let wisp_fn = wisp_mist.handler(fn(req) { router.handle(req, ctx) }, cfg.secret_key) diff --git a/src/server/src/fbi/context.gleam b/src/server/src/fbi/context.gleam index ddf9a1f..00a65cb 100644 --- a/src/server/src/fbi/context.gleam +++ b/src/server/src/fbi/context.gleam @@ -1,4 +1,5 @@ import fbi/config.{type Config} +import fbi/git/mutex.{type Cmd} import fbi/pubsub.{type PubsubMsg} import fbi/run/registry.{type RegistryMsg} import gleam/erlang/process.{type Subject} @@ -10,5 +11,6 @@ pub type Context { config: Config, run_registry: Subject(RegistryMsg), pubsub: Subject(PubsubMsg), + history_mutex: Subject(Cmd), ) } diff --git a/src/server/src/fbi/db/runs.gleam b/src/server/src/fbi/db/runs.gleam index 0c3e0c5..5f9fa69 100644 --- a/src/server/src/fbi/db/runs.gleam +++ b/src/server/src/fbi/db/runs.gleam @@ -1,5 +1,6 @@ import fbi/db/connection.{type DbError, SqlightError} import gleam/dynamic/decode +import simplifile import gleam/int import gleam/json import gleam/option.{type Option, None, Some} @@ -527,3 +528,96 @@ fn nullable_opt(opt: Option(String)) -> sqlight.Value { option.Some(v) -> sqlight.text(v) } } + +pub type ChildSummary { + ChildSummary(id: Int, kind: String, state: String, created_at: Int) +} + +pub fn children_of( + db: sqlight.Connection, + run_id: Int, +) -> Result(List(ChildSummary), DbError) { + let dec = { + use id <- decode.field(0, decode.int) + use kind <- decode.field(1, decode.string) + use state <- decode.field(2, decode.string) + use created_at <- decode.field(3, decode.int) + decode.success(ChildSummary(id, kind, state, created_at)) + } + connection.query_all( + "SELECT id, kind, state, created_at FROM runs WHERE parent_run_id = ? ORDER BY created_at", + db, + [sqlight.int(run_id)], + dec, + ) +} + +pub fn insert_polish_run( + db: sqlight.Connection, + parent: Run, + now: Int, +) -> Result(Run, DbError) { + insert_child_run(db, parent, "polish", read_polish_prompt(), now) +} + +pub fn insert_merge_conflict_run( + db: sqlight.Connection, + parent: Run, + now: Int, +) -> Result(Run, DbError) { + insert_child_run(db, parent, "merge-conflict", merge_conflict_prompt(), now) +} + +fn insert_child_run( + db: sqlight.Connection, + parent: Run, + kind: String, + prompt: String, + now: Int, +) -> Result(Run, DbError) { + let log_path = "/var/log/fbi/runs/" <> int.to_string(now) <> ".log" + connection.query_one( + "INSERT INTO runs + (project_id, prompt, branch_name, state, log_path, created_at, + state_entered_at, parent_run_id, kind) + VALUES (?, ?, ?, 'queued', ?, ?, ?, ?, ?) + RETURNING " <> columns(), + db, + [ + sqlight.int(parent.project_id), + sqlight.text(prompt), + sqlight.text(parent.branch_name), + sqlight.text(log_path), + sqlight.int(now), + sqlight.int(now), + sqlight.int(parent.id), + sqlight.text(kind), + ], + decoder(), + ) +} + +pub fn count_active_children( + db: sqlight.Connection, + parent_id: Int, +) -> Result(Int, DbError) { + connection.query_one( + "SELECT COUNT(*) FROM runs + WHERE parent_run_id = ? + AND state IN ('queued', 'running', 'waiting', 'awaiting_resume')", + db, + [sqlight.int(parent_id)], + decode.at([0], decode.int), + ) +} + +fn read_polish_prompt() -> String { + case simplifile.read("priv/static/polish-prompt.txt") { + Ok(s) -> s + Error(_) -> "Polish the most recent commits on this branch." + } +} + +fn merge_conflict_prompt() -> String { + "Resolve the merge conflicts in /workspace, then commit the resolution. The conflicts were left in place by an automated merge or rebase." +} diff --git a/src/server/src/fbi/docker.gleam b/src/server/src/fbi/docker.gleam index a3e65cd..ce9b88f 100644 --- a/src/server/src/fbi/docker.gleam +++ b/src/server/src/fbi/docker.gleam @@ -769,6 +769,75 @@ fn handle_build_line( } } +pub type ExecResult { + ExecResult(exit_code: Int, output: String) +} + +pub fn exec_container( + sock: Socket, + container_id: String, + cmd: List(String), + user: String, +) -> Result(ExecResult, DockerError) { + // 1. Create the exec instance. + let create_body = + json.object([ + #("AttachStdout", json.bool(True)), + #("AttachStderr", json.bool(True)), + #("Tty", json.bool(False)), + #("User", json.string(user)), + #("Cmd", json.array(cmd, json.string)), + ]) + use #(status, resp) <- result.try(request( + sock, + "POST", + "/containers/" <> container_id <> "/exec", + bit_array.from_string(json.to_string(create_body)), + "application/json", + )) + use exec_id <- result.try(case status { + 201 -> { + use s <- result.try(to_string(resp)) + let dec = { + use id <- decode.field("Id", decode.string) + decode.success(id) + } + json.parse(s, dec) |> result.map_error(fn(_) { DecodeError("exec id") }) + } + code -> Error(HttpError(code, result.unwrap(to_string(resp), ""))) + }) + // 2. Start the exec; capture combined stdout+stderr. + use #(_, start_body) <- result.try(request( + sock, + "POST", + "/exec/" <> exec_id <> "/start", + bit_array.from_string( + json.to_string( + json.object([#("Detach", json.bool(False)), #("Tty", json.bool(False))]), + ), + ), + "application/json", + )) + let output = result.unwrap(to_string(start_body), "") + // 3. Inspect to read exit code. + use #(_, ins_body) <- result.try(request( + sock, + "GET", + "/exec/" <> exec_id <> "/json", + <<>>, + "application/json", + )) + use ins_str <- result.try(to_string(ins_body)) + let exit_dec = { + use code <- decode.field("ExitCode", decode.int) + decode.success(code) + } + case json.parse(ins_str, exit_dec) { + Ok(code) -> Ok(ExecResult(exit_code: code, output: output)) + Error(_) -> Error(DecodeError("exec exit code")) + } +} + // Suppress unused import warning for framing module pub fn unframe_output(b: BitArray) -> Result(BitArray, String) { framing.unframe(b) diff --git a/src/server/src/fbi/git/history_ops.gleam b/src/server/src/fbi/git/history_ops.gleam new file mode 100644 index 0000000..7b794ae --- /dev/null +++ b/src/server/src/fbi/git/history_ops.gleam @@ -0,0 +1,234 @@ +import fbi/config.{type Config} +import fbi/db/projects +import fbi/db/runs as runs_db +import fbi/docker +import fbi/git.{type GitError} +import fbi/run/actor as run_actor +import fbi/run/broadcaster +import fbi/run/registry.{type RegistryMsg, Register} +import fbi/run/types.{type BroadcastMsg, type RunMsg} +import fbi/run/worker as run_worker +import gleam/erlang/process.{type Subject} +import gleam/result +import gleam/string +import sqlight + +pub type Outcome { + Complete(sha: String) + Agent(child_run_id: Int) + Conflict(child_run_id: Int) + GitError(message: String) + Invalid(message: String) +} + +pub type MergeStrategy { + NoFf + FfOnly + Squash +} + +pub type DispatchResult { + AgentDispatched(child_run_id: Int) + AgentBusy + DispatchError(message: String) +} + +pub fn squash_local( + repo_path: String, + branch: String, + base: String, + subject: String, +) -> Result(Outcome, GitError) { + use base_sha <- result_try(rev_parse(repo_path, base)) + use original_tip <- result_try(rev_parse(repo_path, branch)) + use tree <- result_try(rev_parse(repo_path, original_tip <> "^{tree}")) + use new_commit <- result_try( + git.run(repo_path, ["commit-tree", tree, "-p", base_sha, "-m", subject]) + |> result.map(string.trim), + ) + use _ <- result_try(git.run(repo_path, [ + "update-ref", + "refs/heads/" <> branch, + new_commit, + ])) + Ok(Complete(sha: new_commit)) +} + +pub fn mirror_rebase( + repo_path: String, + branch: String, + remote: String, + remote_branch: String, +) -> Result(Outcome, GitError) { + use _ <- result_try(git.run(repo_path, ["fetch", remote, remote_branch])) + use _ <- result_try(git.run(repo_path, [ + "update-ref", + "refs/heads/" <> remote_branch, + "FETCH_HEAD", + ])) + case + git.run(repo_path, [ + "merge-base", + "--is-ancestor", + "FETCH_HEAD", + "refs/heads/" <> branch, + ]) + { + Ok(_) -> Ok(Complete(sha: "")) + Error(_) -> Ok(Conflict(child_run_id: 0)) + } +} + +pub fn sync_in_container( + config: Config, + cid: String, +) -> Result(Outcome, String) { + exec_in_container(config, cid, "cd /workspace && git pull --no-rebase 2>&1") +} + +pub fn merge_in_container( + config: Config, + cid: String, + remote_branch: String, + strategy: MergeStrategy, +) -> Result(Outcome, String) { + let flag = case strategy { + NoFf -> "--no-ff" + FfOnly -> "--ff-only" + Squash -> "--squash" + } + exec_in_container( + config, + cid, + "cd /workspace && git fetch origin " + <> remote_branch + <> " && git merge " + <> flag + <> " FETCH_HEAD 2>&1", + ) +} + +fn exec_in_container( + config: Config, + cid: String, + shell_cmd: String, +) -> Result(Outcome, String) { + case docker.connect(config.docker_socket) { + Error(e) -> Error(docker.describe_error(e)) + Ok(sock) -> { + let r = docker.exec_container(sock, cid, ["sh", "-c", shell_cmd], "agent") + docker.close(sock) + case r { + Error(e) -> Error(docker.describe_error(e)) + Ok(res) -> + case res.exit_code { + 0 -> Ok(Complete(sha: "")) + _ -> + case string.contains(res.output, "CONFLICT") { + True -> Ok(Conflict(child_run_id: 0)) + False -> Ok(GitError(message: res.output)) + } + } + } + } + } +} + +pub fn dispatch_polish( + db: sqlight.Connection, + config: Config, + registry: Subject(RegistryMsg), + parent: runs_db.Run, +) -> DispatchResult { + dispatch_child(db, config, registry, parent, "polish") +} + +pub fn dispatch_merge_conflict( + db: sqlight.Connection, + config: Config, + registry: Subject(RegistryMsg), + parent: runs_db.Run, +) -> DispatchResult { + dispatch_child(db, config, registry, parent, "merge-conflict") +} + +fn dispatch_child( + db: sqlight.Connection, + config: Config, + registry: Subject(RegistryMsg), + parent: runs_db.Run, + kind: String, +) -> DispatchResult { + case runs_db.count_active_children(db, parent.id) { + Ok(n) if n > 0 -> AgentBusy + Error(_) -> DispatchError(message: "could not count children") + Ok(_) -> { + let now = now_ms() + let inserted = case kind { + "polish" -> runs_db.insert_polish_run(db, parent, now) + _ -> runs_db.insert_merge_conflict_run(db, parent, now) + } + case inserted { + Error(_) -> DispatchError(message: "could not insert child") + Ok(child) -> + case projects.get(db, parent.project_id) { + Error(_) -> DispatchError(message: "project missing") + Ok(project) -> + case start_run_actor(db, config, registry, child) { + Error(reason) -> DispatchError(message: reason) + Ok(#(actor_subj, bc)) -> { + run_worker.launch( + run_worker.LaunchInput( + run: child, + project: project, + config: config, + cols: 80, + rows: 24, + broadcaster: bc, + ), + actor_subj, + ) + AgentDispatched(child_run_id: child.id) + } + } + } + } + } + } +} + +fn start_run_actor( + db: sqlight.Connection, + config: Config, + registry: Subject(RegistryMsg), + run: runs_db.Run, +) -> Result(#(Subject(RunMsg), Subject(BroadcastMsg)), String) { + use bc <- result.try( + broadcaster.start() + |> result.map_error(fn(_) { "broadcaster start failed" }), + ) + use actor_subj <- result.try( + run_actor.start(run.id, db, config, bc, registry) + |> result.map_error(fn(_) { "actor start failed" }), + ) + process.send(registry, Register(run.id, actor_subj)) + Ok(#(actor_subj, bc)) +} + +fn rev_parse(repo_path: String, ref: String) -> Result(String, GitError) { + use s <- result_try(git.run(repo_path, ["rev-parse", ref])) + Ok(string.trim(s)) +} + +fn result_try( + r: Result(a, e), + f: fn(a) -> Result(b, e), +) -> Result(b, e) { + case r { + Ok(v) -> f(v) + Error(e) -> Error(e) + } +} + +@external(erlang, "fbi_time", "now_ms") +fn now_ms() -> Int diff --git a/src/server/src/fbi/git/mutex.gleam b/src/server/src/fbi/git/mutex.gleam new file mode 100644 index 0000000..12fb394 --- /dev/null +++ b/src/server/src/fbi/git/mutex.gleam @@ -0,0 +1,46 @@ +import gleam/erlang/process.{type Subject} +import gleam/otp/actor +import gleam/result +import gleam/set.{type Set} + +pub type Cmd { + TryAcquire(run_id: Int, reply: Subject(Bool)) + Release(run_id: Int) +} + +pub fn start() -> Result(Subject(Cmd), actor.StartError) { + actor.new(set.new()) + |> actor.on_message(fn(state, msg) { handle(state, msg) }) + |> actor.start + |> result.map(fn(s) { s.data }) +} + +fn handle(state: Set(Int), msg: Cmd) -> actor.Next(Set(Int), Cmd) { + case msg { + TryAcquire(id, reply) -> + case set.contains(state, id) { + True -> { + process.send(reply, False) + actor.continue(state) + } + False -> { + process.send(reply, True) + actor.continue(set.insert(state, id)) + } + } + Release(id) -> actor.continue(set.delete(state, id)) + } +} + +pub fn try_acquire(mutex: Subject(Cmd), run_id: Int) -> Bool { + let reply = process.new_subject() + process.send(mutex, TryAcquire(run_id, reply)) + case process.receive(reply, 200) { + Ok(b) -> b + Error(_) -> False + } +} + +pub fn release(mutex: Subject(Cmd), run_id: Int) -> Nil { + process.send(mutex, Release(run_id)) +} diff --git a/src/server/src/fbi/handlers/changes.gleam b/src/server/src/fbi/handlers/changes.gleam index f8f4a2d..574f36d 100644 --- a/src/server/src/fbi/handlers/changes.gleam +++ b/src/server/src/fbi/handlers/changes.gleam @@ -1,43 +1,107 @@ import fbi/context.{type Context} +import fbi/db/projects +import fbi/db/runs as runs_db +import fbi/git/parse +import fbi/git/repo import gleam/http +import gleam/int import gleam/json +import gleam/list +import gleam/option +import gleam/result import wisp.{type Request, type Response} -/// All git-introspection endpoints (changes, file-diff, commits, wip, etc.) -/// return graceful empty responses until the git plumbing is implemented. -/// This stops the frontend from showing 404 errors while keeping the -/// behaviour consistent with the documented out-of-scope checklist. -pub fn handle_changes(req: Request, _ctx: Context, _id: String) -> Response { +pub fn handle_changes(req: Request, ctx: Context, id_str: String) -> Response { case req.method { http.Get -> + case int.parse(id_str) { + Error(_) -> wisp.bad_request("Invalid run ID") + Ok(id) -> serve_changes(ctx, id) + } + _ -> wisp.method_not_allowed([http.Get]) + } +} + +fn serve_changes(ctx: Context, run_id: Int) -> Response { + case runs_db.get(ctx.db, run_id) { + Error(_) -> wisp.not_found() + Ok(run) -> { + let repo_path = + ctx.config.runs_dir <> "/" <> int.to_string(run_id) <> "/wip" + let default = resolve_default_branch(ctx, run.project_id) + let branch = run.branch_name + let commits = + repo.commits_on_branch(repo_path, branch, default) + |> result.unwrap([]) + let commits_with_files = + list.map(commits, fn(c) { + let files = repo.commit_files(repo_path, c.sha) |> result.unwrap([]) + #(c, files) + }) + let pushed_all = run.mirror_status == option.Some("ok") + let base = + repo.branch_base_ahead_behind(repo_path, branch, default) + |> result.map(option.Some) + |> result.unwrap(option.None) + let uncommitted = case repo.wip_files(repo_path) { + Ok(option.Some(snap)) -> snap.files + _ -> [] + } + let children = + runs_db.children_of(ctx.db, run_id) |> result.unwrap([]) json.object([ - #("branch_name", json.null()), - #("branch_base", json.null()), - #("commits", json.array([], json.string)), - #("uncommitted", json.array([], json.string)), + #("branch_name", json.string(branch)), + #("branch_base", encode_branch_base(base)), + #( + "commits", + json.array(commits_with_files, fn(pair) { + let #(c, files) = pair + encode_commit(c, files, pushed_all) + }), + ), + #("uncommitted", json.array(uncommitted, encode_file)), #("integrations", json.object([])), + #("dirty_submodules", json.array([], json.string)), + #("children", json.array(children, encode_child)), ]) |> json.to_string() |> wisp.json_response(200) - _ -> wisp.method_not_allowed([http.Get]) + } } } pub fn handle_commit_files( req: Request, - _ctx: Context, - _id: String, - _sha: String, + ctx: Context, + id_str: String, + sha: String, ) -> Response { case req.method { http.Get -> - json.object([#("files", json.array([], json.string))]) - |> json.to_string() - |> wisp.json_response(200) + case int.parse(id_str) { + Error(_) -> wisp.bad_request("Invalid run ID") + Ok(id) -> serve_commit_files(ctx, id, sha) + } _ -> wisp.method_not_allowed([http.Get]) } } +fn serve_commit_files(ctx: Context, run_id: Int, sha: String) -> Response { + let repo_path = + ctx.config.runs_dir <> "/" <> int.to_string(run_id) <> "/wip" + case runs_db.get(ctx.db, run_id) { + Error(_) -> wisp.not_found() + Ok(_) -> + case repo.commit_files(repo_path, sha) { + Error(_) -> wisp.not_found() + Ok(files) -> + json.object([#("files", json.array(files, encode_file))]) + |> json.to_string() + |> wisp.json_response(200) + } + } +} + pub fn handle_submodule_commit_files( req: Request, _ctx: Context, @@ -54,30 +118,140 @@ pub fn handle_submodule_commit_files( } } -pub fn handle_file_diff(req: Request, _ctx: Context, _id: String) -> Response { +pub fn handle_file_diff(req: Request, ctx: Context, id_str: String) -> Response { case req.method { http.Get -> - json.object([ - #("path", json.string("")), - #("ref", json.string("worktree")), - #("hunks", json.array([], json.string)), - #("truncated", json.bool(False)), - ]) - |> json.to_string() - |> wisp.json_response(200) + case int.parse(id_str) { + Error(_) -> wisp.bad_request("Invalid run ID") + Ok(id) -> serve_file_diff(req, ctx, id) + } _ -> wisp.method_not_allowed([http.Get]) } } -pub fn handle_history(req: Request, _ctx: Context, _id: String) -> Response { - case req.method { - http.Post -> +fn serve_file_diff(req: Request, ctx: Context, run_id: Int) -> Response { + let qs = wisp.get_query(req) + let path = list.key_find(qs, "path") |> result.unwrap("") + let ref = list.key_find(qs, "ref") |> result.unwrap("worktree") + let repo_path = + ctx.config.runs_dir <> "/" <> int.to_string(run_id) <> "/wip" + case path { + "" -> wisp.bad_request("path is required") + _ -> + case ref { + "worktree" -> serve_worktree_diff(repo_path, path) + sha -> serve_commit_diff(repo_path, sha, path) + } + } +} + +fn serve_worktree_diff(repo_path: String, path: String) -> Response { + case repo.wip_file_diff(repo_path, path) { + Error(_) -> wisp.internal_server_error() + Ok(option.None) -> empty_diff_response("worktree", path) + Ok(option.Some(#(hunks, truncated, _, _))) -> + diff_response(hunks, truncated, "worktree", path) + } +} + +fn serve_commit_diff(repo_path: String, sha: String, path: String) -> Response { + case repo.file_diff(repo_path, sha, path) { + Error(_) -> wisp.not_found() + Ok(#(hunks, truncated)) -> diff_response(hunks, truncated, sha, path) + } +} + +fn empty_diff_response(ref: String, path: String) -> Response { + json.object([ + #("path", json.string(path)), + #("ref", json.string(ref)), + #("hunks", json.array([], encode_hunk)), + #("truncated", json.bool(False)), + ]) + |> json.to_string() + |> wisp.json_response(200) +} + +fn diff_response( + hunks: List(parse.Hunk), + truncated: Bool, + ref: String, + path: String, +) -> Response { + json.object([ + #("path", json.string(path)), + #("ref", json.string(ref)), + #("hunks", json.array(hunks, encode_hunk)), + #("truncated", json.bool(truncated)), + ]) + |> json.to_string() + |> wisp.json_response(200) +} + +fn encode_hunk(h: parse.Hunk) -> json.Json { + json.object([ + #("header", json.string(h.header)), + #( + "lines", + json.array(h.lines, fn(l) { + json.object([ + #("kind", json.string(l.kind)), + #("text", json.string(l.text)), + ]) + }), + ), + ]) +} + +fn encode_file(f: repo.FileEntry) -> json.Json { + json.object([ + #("path", json.string(f.path)), + #("status", json.string(f.status)), + #("additions", json.int(f.additions)), + #("deletions", json.int(f.deletions)), + ]) +} + +fn encode_branch_base(b: option.Option(repo.BranchBase)) -> json.Json { + case b { + option.None -> json.null() + option.Some(bb) -> json.object([ - #("kind", json.string("git-unavailable")), - #("message", json.string("git operations not yet implemented")), + #("base", json.string(bb.base)), + #("ahead", json.int(bb.ahead)), + #("behind", json.int(bb.behind)), ]) - |> json.to_string() - |> wisp.json_response(200) - _ -> wisp.method_not_allowed([http.Post]) + } +} + +fn encode_commit( + c: parse.LogEntry, + files: List(repo.FileEntry), + pushed: Bool, +) -> json.Json { + json.object([ + #("sha", json.string(c.sha)), + #("subject", json.string(c.subject)), + #("committed_at", json.int(c.committed_at)), + #("pushed", json.bool(pushed)), + #("files", json.array(files, encode_file)), + #("files_loaded", json.bool(True)), + #("submodule_bumps", json.array([], json.string)), + ]) +} + +fn encode_child(c: runs_db.ChildSummary) -> json.Json { + json.object([ + #("id", json.int(c.id)), + #("kind", json.string(c.kind)), + #("state", json.string(c.state)), + #("created_at", json.int(c.created_at)), + ]) +} + +fn resolve_default_branch(ctx: Context, project_id: Int) -> String { + case projects.get(ctx.db, project_id) { + Ok(p) -> p.default_branch + Error(_) -> "main" } } diff --git a/src/server/src/fbi/handlers/history.gleam b/src/server/src/fbi/handlers/history.gleam new file mode 100644 index 0000000..a4e85a5 --- /dev/null +++ b/src/server/src/fbi/handlers/history.gleam @@ -0,0 +1,224 @@ +import fbi/context.{type Context} +import fbi/db/projects +import fbi/db/runs as runs_db +import fbi/git +import fbi/git/history_ops +import fbi/git/mutex as history_mutex +import gleam/dynamic +import gleam/dynamic/decode +import gleam/http +import gleam/int +import gleam/json +import gleam/option.{None, Some} +import gleam/result +import wisp.{type Request, type Response} + +pub fn handle(req: Request, ctx: Context, id_str: String) -> Response { + case req.method { + http.Post -> + case int.parse(id_str) { + Error(_) -> wisp.bad_request("Invalid run ID") + Ok(id) -> dispatch(req, ctx, id) + } + _ -> wisp.method_not_allowed([http.Post]) + } +} + +fn dispatch(req: Request, ctx: Context, run_id: Int) -> Response { + use body <- wisp.require_json(req) + let op_decoder = { + use op <- decode.field("op", decode.string) + decode.success(op) + } + case decode.run(body, op_decoder) { + Error(_) -> result_response(history_ops.Invalid(message: "missing op")) + Ok(op) -> { + case history_mutex.try_acquire(ctx.history_mutex, run_id) { + False -> + result_response(history_ops.Invalid(message: "agent-busy")) + True -> { + let outcome = run_op(ctx, run_id, op, body) + history_mutex.release(ctx.history_mutex, run_id) + result_response(outcome) + } + } + } + } +} + +fn run_op( + ctx: Context, + run_id: Int, + op: String, + body: dynamic.Dynamic, +) -> history_ops.Outcome { + case runs_db.get(ctx.db, run_id) { + Error(_) -> history_ops.Invalid(message: "run not found") + Ok(run) -> { + let repo_path = + ctx.config.runs_dir <> "/" <> int.to_string(run_id) <> "/wip" + let default = resolve_default_branch(ctx, run.project_id) + case op { + "squash-local" -> { + let dec = { + use subject <- decode.field("subject", decode.string) + decode.success(subject) + } + case decode.run(body, dec) { + Ok(subject) -> + case + history_ops.squash_local( + repo_path, + run.branch_name, + default, + subject, + ) + { + Ok(o) -> o + Error(e) -> history_ops.GitError(message: git.describe_error(e)) + } + Error(_) -> history_ops.Invalid(message: "subject required") + } + } + "mirror-rebase" -> + case + history_ops.mirror_rebase( + repo_path, + run.branch_name, + "origin", + default, + ) + { + Ok(o) -> dispatch_if_conflict(ctx, run, o) + Error(e) -> history_ops.GitError(message: git.describe_error(e)) + } + "sync" -> + case run.container_id { + None -> + history_ops.GitError(message: "container not running") + Some(cid) -> + case history_ops.sync_in_container(ctx.config, cid) { + Ok(o) -> dispatch_if_conflict(ctx, run, o) + Error(e) -> history_ops.GitError(message: e) + } + } + "merge" -> { + let strat_dec = { + use strat <- decode.optional_field( + "strategy", + "no-ff", + decode.string, + ) + decode.success(strat) + } + let strategy = case decode.run(body, strat_dec) |> result.unwrap("no-ff") { + "ff-only" -> history_ops.FfOnly + "squash" -> history_ops.Squash + _ -> history_ops.NoFf + } + case run.container_id { + None -> + history_ops.GitError(message: "container not running") + Some(cid) -> + case + history_ops.merge_in_container( + ctx.config, + cid, + default, + strategy, + ) + { + Ok(o) -> dispatch_if_conflict(ctx, run, o) + Error(e) -> history_ops.GitError(message: e) + } + } + } + "polish" -> + case + history_ops.dispatch_polish( + ctx.db, + ctx.config, + ctx.run_registry, + run, + ) + { + history_ops.AgentDispatched(child_id) -> + history_ops.Agent(child_run_id: child_id) + history_ops.AgentBusy -> + history_ops.Invalid(message: "agent-busy") + history_ops.DispatchError(m) -> + history_ops.GitError(message: m) + } + "push-submodule" -> + history_ops.Invalid( + message: "submodules not supported in this build", + ) + _ -> history_ops.Invalid(message: "unknown op: " <> op) + } + } + } +} + +fn dispatch_if_conflict( + ctx: Context, + run: runs_db.Run, + outcome: history_ops.Outcome, +) -> history_ops.Outcome { + case outcome { + history_ops.Conflict(_) -> + case + history_ops.dispatch_merge_conflict( + ctx.db, + ctx.config, + ctx.run_registry, + run, + ) + { + history_ops.AgentDispatched(child_id) -> + history_ops.Conflict(child_run_id: child_id) + history_ops.AgentBusy -> + history_ops.Invalid(message: "agent-busy") + history_ops.DispatchError(m) -> + history_ops.GitError(message: m) + } + o -> o + } +} + +fn result_response(o: history_ops.Outcome) -> Response { + let body = case o { + history_ops.Complete(sha) -> + json.object([ + #("kind", json.string("complete")), + #("sha", json.string(sha)), + ]) + history_ops.Agent(child_id) -> + json.object([ + #("kind", json.string("agent")), + #("child_run_id", json.int(child_id)), + ]) + history_ops.Conflict(child_id) -> + json.object([ + #("kind", json.string("conflict")), + #("child_run_id", json.int(child_id)), + ]) + history_ops.Invalid(m) -> + json.object([ + #("kind", json.string("invalid")), + #("message", json.string(m)), + ]) + history_ops.GitError(m) -> + json.object([ + #("kind", json.string("git-error")), + #("message", json.string(m)), + ]) + } + body |> json.to_string() |> wisp.json_response(200) +} + +fn resolve_default_branch(ctx: Context, project_id: Int) -> String { + case projects.get(ctx.db, project_id) { + Ok(p) -> p.default_branch + Error(_) -> "main" + } +} diff --git a/src/server/src/fbi/handlers/wip.gleam b/src/server/src/fbi/handlers/wip.gleam index c7b849b..a6a2575 100644 --- a/src/server/src/fbi/handlers/wip.gleam +++ b/src/server/src/fbi/handlers/wip.gleam @@ -1,51 +1,210 @@ import fbi/context.{type Context} +import fbi/db/runs as runs_db +import fbi/docker +import fbi/git/parse +import fbi/git/repo +import fbi/run/registry as run_registry import gleam/http +import gleam/int import gleam/json +import gleam/list +import gleam/option.{None, Some} +import gleam/result import wisp.{type Request, type Response} -/// WIP endpoints (status, file diff, discard, patch download). These all -/// return graceful empty/no-wip responses until git plumbing is added. -pub fn handle_status(req: Request, _ctx: Context, _id: String) -> Response { +pub fn handle_status(req: Request, ctx: Context, id_str: String) -> Response { case req.method { http.Get -> - json.object([ - #("ok", json.bool(False)), - #("reason", json.string("no-wip")), - ]) - |> json.to_string() - |> wisp.json_response(200) + with_repo_path(ctx, id_str, fn(_run, repo_path) { + case repo.wip_files(repo_path) { + Error(_) -> wisp.internal_server_error() + Ok(None) -> no_wip_response() + Ok(Some(snap)) -> + json.object([ + #("ok", json.bool(True)), + #("snapshot_sha", json.string(snap.snapshot_sha)), + #("parent_sha", json.string(snap.parent_sha)), + #("files", json.array(snap.files, encode_file)), + ]) + |> json.to_string() + |> wisp.json_response(200) + } + }) _ -> wisp.method_not_allowed([http.Get]) } } -pub fn handle_file(req: Request, _ctx: Context, _id: String) -> Response { +pub fn handle_file(req: Request, ctx: Context, id_str: String) -> Response { case req.method { http.Get -> - json.object([ - #("path", json.string("")), - #("ref", json.string("worktree")), - #("hunks", json.array([], json.string)), - #("truncated", json.bool(False)), - ]) - |> json.to_string() - |> wisp.json_response(200) + with_repo_path(ctx, id_str, fn(_run, repo_path) { + let qs = wisp.get_query(req) + let path = list.key_find(qs, "path") |> result.unwrap("") + case path { + "" -> wisp.bad_request("path is required") + _ -> + case repo.wip_file_diff(repo_path, path) { + Error(_) -> wisp.internal_server_error() + Ok(None) -> empty_diff(path) + Ok(Some(#(hunks, truncated, _, _))) -> + json.object([ + #("path", json.string(path)), + #("ref", json.string("worktree")), + #("hunks", json.array(hunks, encode_hunk)), + #("truncated", json.bool(truncated)), + ]) + |> json.to_string() + |> wisp.json_response(200) + } + } + }) _ -> wisp.method_not_allowed([http.Get]) } } -pub fn handle_discard(req: Request, _ctx: Context, _id: String) -> Response { +pub fn handle_patch(req: Request, ctx: Context, id_str: String) -> Response { case req.method { - http.Post -> wisp.response(204) - _ -> wisp.method_not_allowed([http.Post]) + http.Get -> + with_repo_path(ctx, id_str, fn(_run, repo_path) { + case repo.wip_patch(repo_path) { + Error(_) -> wisp.internal_server_error() + Ok(body) -> + wisp.response(200) + |> wisp.set_header("content-type", "text/x-patch; charset=utf-8") + |> wisp.set_header( + "content-disposition", + "attachment; filename=\"run-" <> id_str <> "-wip.patch\"", + ) + |> wisp.set_body(wisp.Text(body)) + } + }) + _ -> wisp.method_not_allowed([http.Get]) } } -pub fn handle_patch(req: Request, _ctx: Context, _id: String) -> Response { +pub fn handle_discard(req: Request, ctx: Context, id_str: String) -> Response { case req.method { - http.Get -> - wisp.response(200) - |> wisp.set_header("content-type", "text/plain; charset=utf-8") - |> wisp.set_body(wisp.Text("")) - _ -> wisp.method_not_allowed([http.Get]) + http.Post -> + case int.parse(id_str) { + Error(_) -> wisp.bad_request("Invalid run ID") + Ok(id) -> do_discard(ctx, id) + } + _ -> wisp.method_not_allowed([http.Post]) } } + +fn do_discard(ctx: Context, run_id: Int) -> Response { + case runs_db.get(ctx.db, run_id) { + Error(_) -> wisp.not_found() + Ok(run) -> + case run.container_id { + None -> conflict_no_container() + Some(cid) -> + case run_registry.lookup(ctx.run_registry, run_id) { + None -> conflict_no_container() + _ -> exec_discard(ctx, cid) + } + } + } +} + +fn exec_discard(ctx: Context, cid: String) -> Response { + case docker.connect(ctx.config.docker_socket) { + Error(e) -> { + wisp.log_warning("wip discard connect: " <> docker.describe_error(e)) + wisp.internal_server_error() + } + Ok(sock) -> { + let result = + docker.exec_container( + sock, + cid, + [ + "sh", + "-c", + "cd /workspace && git restore --staged --worktree . && git clean -fd", + ], + "agent", + ) + docker.close(sock) + case result { + Ok(_) -> wisp.response(204) + Error(e) -> { + wisp.log_warning("wip discard exec: " <> docker.describe_error(e)) + json.object([ + #("kind", json.string("git-error")), + #("message", json.string(docker.describe_error(e))), + ]) + |> json.to_string() + |> wisp.json_response(500) + } + } + } + } +} + +fn with_repo_path( + ctx: Context, + id_str: String, + next: fn(runs_db.Run, String) -> Response, +) -> Response { + case int.parse(id_str) { + Error(_) -> wisp.bad_request("Invalid run ID") + Ok(id) -> + case runs_db.get(ctx.db, id) { + Error(_) -> wisp.not_found() + Ok(run) -> { + let path = + ctx.config.runs_dir <> "/" <> int.to_string(id) <> "/wip" + next(run, path) + } + } + } +} + +fn no_wip_response() -> Response { + json.object([#("ok", json.bool(False)), #("reason", json.string("no-wip"))]) + |> json.to_string() + |> wisp.json_response(200) +} + +fn empty_diff(path: String) -> Response { + json.object([ + #("path", json.string(path)), + #("ref", json.string("worktree")), + #("hunks", json.array([], encode_hunk)), + #("truncated", json.bool(False)), + ]) + |> json.to_string() + |> wisp.json_response(200) +} + +fn conflict_no_container() -> Response { + json.object([#("error", json.string("container_not_running"))]) + |> json.to_string() + |> wisp.json_response(409) +} + +fn encode_file(f: repo.FileEntry) -> json.Json { + json.object([ + #("path", json.string(f.path)), + #("status", json.string(f.status)), + #("additions", json.int(f.additions)), + #("deletions", json.int(f.deletions)), + ]) +} + +fn encode_hunk(h: parse.Hunk) -> json.Json { + json.object([ + #("header", json.string(h.header)), + #( + "lines", + json.array(h.lines, fn(l) { + json.object([ + #("kind", json.string(l.kind)), + #("text", json.string(l.text)), + ]) + }), + ), + ]) +} diff --git a/src/server/src/fbi/router.gleam b/src/server/src/fbi/router.gleam index a40a747..6dd3cab 100644 --- a/src/server/src/fbi/router.gleam +++ b/src/server/src/fbi/router.gleam @@ -2,6 +2,7 @@ import fbi/context.{type Context} import fbi/handlers/changes as changes_handler import fbi/handlers/config as config_handler import fbi/handlers/github as github_handler +import fbi/handlers/history as history_handler import fbi/handlers/health import fbi/handlers/listening_ports as listening_ports_handler import fbi/handlers/mcp_servers as mcp_servers_handler @@ -71,7 +72,7 @@ pub fn handle(req: Request, ctx: Context) -> Response { ["api", "runs", id, "file-diff"] -> changes_handler.handle_file_diff(req, ctx, id) ["api", "runs", id, "history"] -> - changes_handler.handle_history(req, ctx, id) + history_handler.handle(req, ctx, id) ["api", "runs", id, "commits", sha, "files"] -> changes_handler.handle_commit_files(req, ctx, id, sha) ["api", "runs", id, "submodule", path, "commits", sha, "files"] -> From 8cf050262ba32051b81f9642f5aafd0c037d39f4 Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Thu, 30 Apr 2026 09:34:31 +0000 Subject: [PATCH 6/7] chore: pin gleam 1.16.0-rc4 for gleam_stdlib 1.0.0 compatibility --- src/server/.tool-versions | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/server/.tool-versions diff --git a/src/server/.tool-versions b/src/server/.tool-versions new file mode 100644 index 0000000..1b2d0e9 --- /dev/null +++ b/src/server/.tool-versions @@ -0,0 +1 @@ +gleam 1.16.0-rc4 From 698fc1eb55c6e914c55489a7263343385ab1657e Mon Sep 17 00:00:00 2001 From: Fynn Datoo Date: Thu, 30 Apr 2026 05:18:01 -0700 Subject: [PATCH 7/7] chore: gleam format Fixes the "Format check" step on this PR's CI. Co-Authored-By: Claude Opus 4.7 --- src/server/src/fbi.gleam | 2 +- src/server/src/fbi/db/runs.gleam | 29 ++-- src/server/src/fbi/git/history_ops.gleam | 29 ++-- src/server/src/fbi/git/parse.gleam | 3 +- src/server/src/fbi/git/repo.gleam | 158 ++++++++++++---------- src/server/src/fbi/handlers/changes.gleam | 15 +- src/server/src/fbi/handlers/history.gleam | 29 ++-- src/server/src/fbi/handlers/wip.gleam | 3 +- src/server/src/fbi/router.gleam | 5 +- src/server/test/fbi/git/parse_test.gleam | 11 +- src/server/test/fbi/git/repo_test.gleam | 6 +- 11 files changed, 149 insertions(+), 141 deletions(-) diff --git a/src/server/src/fbi.gleam b/src/server/src/fbi.gleam index af8e243..e4ff9f4 100644 --- a/src/server/src/fbi.gleam +++ b/src/server/src/fbi.gleam @@ -3,10 +3,10 @@ import fbi/config import fbi/context.{Context} import fbi/db/connection import fbi/db/migrations +import fbi/git/mutex as history_mutex import fbi/handlers/shell_ws import fbi/handlers/states_ws import fbi/handlers/usage_ws -import fbi/git/mutex as history_mutex import fbi/pubsub import fbi/router import fbi/run/gc_scheduler diff --git a/src/server/src/fbi/db/runs.gleam b/src/server/src/fbi/db/runs.gleam index dc39580..94d6ac0 100644 --- a/src/server/src/fbi/db/runs.gleam +++ b/src/server/src/fbi/db/runs.gleam @@ -1,11 +1,11 @@ import fbi/db/connection.{type DbError, SqlightError} import gleam/dynamic/decode -import simplifile import gleam/int import gleam/json import gleam/option.{type Option, None, Some} import gleam/result import gleam/string +import simplifile import sqlight pub type Run { @@ -620,25 +620,20 @@ fn insert_child_run( now: Int, ) -> Result(Run, DbError) { let log_path = "/var/log/fbi/runs/" <> int.to_string(now) <> ".log" - connection.query_one( - "INSERT INTO runs + connection.query_one("INSERT INTO runs (project_id, prompt, branch_name, state, log_path, created_at, state_entered_at, parent_run_id, kind) VALUES (?, ?, ?, 'queued', ?, ?, ?, ?, ?) - RETURNING " <> columns(), - db, - [ - sqlight.int(parent.project_id), - sqlight.text(prompt), - sqlight.text(parent.branch_name), - sqlight.text(log_path), - sqlight.int(now), - sqlight.int(now), - sqlight.int(parent.id), - sqlight.text(kind), - ], - decoder(), - ) + RETURNING " <> columns(), db, [ + sqlight.int(parent.project_id), + sqlight.text(prompt), + sqlight.text(parent.branch_name), + sqlight.text(log_path), + sqlight.int(now), + sqlight.int(now), + sqlight.int(parent.id), + sqlight.text(kind), + ], decoder()) } pub fn count_active_children( diff --git a/src/server/src/fbi/git/history_ops.gleam b/src/server/src/fbi/git/history_ops.gleam index 7b794ae..7a904c8 100644 --- a/src/server/src/fbi/git/history_ops.gleam +++ b/src/server/src/fbi/git/history_ops.gleam @@ -46,11 +46,13 @@ pub fn squash_local( git.run(repo_path, ["commit-tree", tree, "-p", base_sha, "-m", subject]) |> result.map(string.trim), ) - use _ <- result_try(git.run(repo_path, [ - "update-ref", - "refs/heads/" <> branch, - new_commit, - ])) + use _ <- result_try( + git.run(repo_path, [ + "update-ref", + "refs/heads/" <> branch, + new_commit, + ]), + ) Ok(Complete(sha: new_commit)) } @@ -61,11 +63,13 @@ pub fn mirror_rebase( remote_branch: String, ) -> Result(Outcome, GitError) { use _ <- result_try(git.run(repo_path, ["fetch", remote, remote_branch])) - use _ <- result_try(git.run(repo_path, [ - "update-ref", - "refs/heads/" <> remote_branch, - "FETCH_HEAD", - ])) + use _ <- result_try( + git.run(repo_path, [ + "update-ref", + "refs/heads/" <> remote_branch, + "FETCH_HEAD", + ]), + ) case git.run(repo_path, [ "merge-base", @@ -220,10 +224,7 @@ fn rev_parse(repo_path: String, ref: String) -> Result(String, GitError) { Ok(string.trim(s)) } -fn result_try( - r: Result(a, e), - f: fn(a) -> Result(b, e), -) -> Result(b, e) { +fn result_try(r: Result(a, e), f: fn(a) -> Result(b, e)) -> Result(b, e) { case r { Ok(v) -> f(v) Error(e) -> Error(e) diff --git a/src/server/src/fbi/git/parse.gleam b/src/server/src/fbi/git/parse.gleam index 049fb53..f278395 100644 --- a/src/server/src/fbi/git/parse.gleam +++ b/src/server/src/fbi/git/parse.gleam @@ -182,8 +182,7 @@ fn parse_status_record(rec: String) -> Result(StatusEntry, Nil) { "" -> Error(Nil) _ -> case string.first(rec) { - Ok("?") -> - Ok(StatusEntry(status: "U", path: string.drop_start(rec, 1))) + Ok("?") -> Ok(StatusEntry(status: "U", path: string.drop_start(rec, 1))) Ok("1") -> parse_v2_ordinary(rec) Ok("2") -> parse_v2_rename(rec) Ok("u") -> parse_v2_unmerged(rec) diff --git a/src/server/src/fbi/git/repo.gleam b/src/server/src/fbi/git/repo.gleam index c92abeb..a42b739 100644 --- a/src/server/src/fbi/git/repo.gleam +++ b/src/server/src/fbi/git/repo.gleam @@ -27,11 +27,13 @@ pub fn commits_on_branch( branch: String, base: String, ) -> Result(List(parse.LogEntry), GitError) { - use output <- result_map(git.run(repo_path, [ - "log", - base <> ".." <> branch, - "--pretty=format:%H%x00%s%x00%ct", - ])) + use output <- result_map( + git.run(repo_path, [ + "log", + base <> ".." <> branch, + "--pretty=format:%H%x00%s%x00%ct", + ]), + ) parse.parse_log_porcelain(output) } @@ -39,20 +41,24 @@ pub fn commit_files( repo_path: String, sha: String, ) -> Result(List(FileEntry), GitError) { - use ns_output <- result_try(git.run(repo_path, [ - "show", - "--no-renames", - "--pretty=", - "--name-status", - sha, - ])) - use num_output <- result_try(git.run(repo_path, [ - "show", - "--no-renames", - "--pretty=", - "--numstat", - sha, - ])) + use ns_output <- result_try( + git.run(repo_path, [ + "show", + "--no-renames", + "--pretty=", + "--name-status", + sha, + ]), + ) + use num_output <- result_try( + git.run(repo_path, [ + "show", + "--no-renames", + "--pretty=", + "--numstat", + sha, + ]), + ) let names = parse.parse_name_status(ns_output) let nums = parse.parse_numstat(num_output) Ok(merge_names_and_nums(names, nums)) @@ -67,14 +73,16 @@ pub fn file_diff( True -> sha <> "^" False -> empty_tree_sha } - use output <- result_try(git.run(repo_path, [ - "diff", - "--no-color", - parent_arg, - sha, - "--", - path, - ])) + use output <- result_try( + git.run(repo_path, [ + "diff", + "--no-color", + parent_arg, + sha, + "--", + path, + ]), + ) let truncated = string.byte_size(output) > diff_byte_cap let body = case truncated { True -> string.slice(output, 0, diff_byte_cap) @@ -88,16 +96,20 @@ pub fn branch_base_ahead_behind( branch: String, default: String, ) -> Result(BranchBase, GitError) { - use ahead_str <- result_try(git.run(repo_path, [ - "rev-list", - "--count", - default <> ".." <> branch, - ])) - use behind_str <- result_try(git.run(repo_path, [ - "rev-list", - "--count", - branch <> ".." <> default, - ])) + use ahead_str <- result_try( + git.run(repo_path, [ + "rev-list", + "--count", + default <> ".." <> branch, + ]), + ) + use behind_str <- result_try( + git.run(repo_path, [ + "rev-list", + "--count", + branch <> ".." <> default, + ]), + ) Ok(BranchBase( base: default, ahead: parse_int_first_line(ahead_str), @@ -110,25 +122,31 @@ pub fn wip_files(repo_path: String) -> Result(Option(WipSnapshot), GitError) { Error(_) -> Ok(None) Ok(snapshot_str) -> { let snapshot_sha = string.trim(snapshot_str) - use parent_str <- result_try(git.run(repo_path, [ - "rev-parse", - snapshot_sha <> "^", - ])) + use parent_str <- result_try( + git.run(repo_path, [ + "rev-parse", + snapshot_sha <> "^", + ]), + ) let parent_sha = string.trim(parent_str) - use ns_output <- result_try(git.run(repo_path, [ - "diff", - "--no-renames", - "--name-status", - parent_sha, - snapshot_sha, - ])) - use num_output <- result_try(git.run(repo_path, [ - "diff", - "--no-renames", - "--numstat", - parent_sha, - snapshot_sha, - ])) + use ns_output <- result_try( + git.run(repo_path, [ + "diff", + "--no-renames", + "--name-status", + parent_sha, + snapshot_sha, + ]), + ) + use num_output <- result_try( + git.run(repo_path, [ + "diff", + "--no-renames", + "--numstat", + parent_sha, + snapshot_sha, + ]), + ) let files = merge_names_and_nums( parse.parse_name_status(ns_output), @@ -137,11 +155,13 @@ pub fn wip_files(repo_path: String) -> Result(Option(WipSnapshot), GitError) { case files { [] -> Ok(None) _ -> - Ok(Some(WipSnapshot( - snapshot_sha: snapshot_sha, - parent_sha: parent_sha, - files: files, - ))) + Ok( + Some(WipSnapshot( + snapshot_sha: snapshot_sha, + parent_sha: parent_sha, + files: files, + )), + ) } } } @@ -155,14 +175,16 @@ pub fn wip_file_diff( Ok(None) -> Ok(None) Error(e) -> Error(e) Ok(Some(snap)) -> { - use output <- result_try(git.run(repo_path, [ - "diff", - "--no-color", - snap.parent_sha, - snap.snapshot_sha, - "--", - path, - ])) + use output <- result_try( + git.run(repo_path, [ + "diff", + "--no-color", + snap.parent_sha, + snap.snapshot_sha, + "--", + path, + ]), + ) let truncated = string.byte_size(output) > diff_byte_cap let body = case truncated { True -> string.slice(output, 0, diff_byte_cap) diff --git a/src/server/src/fbi/handlers/changes.gleam b/src/server/src/fbi/handlers/changes.gleam index 574f36d..bf7f078 100644 --- a/src/server/src/fbi/handlers/changes.gleam +++ b/src/server/src/fbi/handlers/changes.gleam @@ -47,8 +47,7 @@ fn serve_changes(ctx: Context, run_id: Int) -> Response { Ok(option.Some(snap)) -> snap.files _ -> [] } - let children = - runs_db.children_of(ctx.db, run_id) |> result.unwrap([]) + let children = runs_db.children_of(ctx.db, run_id) |> result.unwrap([]) json.object([ #("branch_name", json.string(branch)), #("branch_base", encode_branch_base(base)), @@ -87,8 +86,7 @@ pub fn handle_commit_files( } fn serve_commit_files(ctx: Context, run_id: Int, sha: String) -> Response { - let repo_path = - ctx.config.runs_dir <> "/" <> int.to_string(run_id) <> "/wip" + let repo_path = ctx.config.runs_dir <> "/" <> int.to_string(run_id) <> "/wip" case runs_db.get(ctx.db, run_id) { Error(_) -> wisp.not_found() Ok(_) -> @@ -118,7 +116,11 @@ pub fn handle_submodule_commit_files( } } -pub fn handle_file_diff(req: Request, ctx: Context, id_str: String) -> Response { +pub fn handle_file_diff( + req: Request, + ctx: Context, + id_str: String, +) -> Response { case req.method { http.Get -> case int.parse(id_str) { @@ -133,8 +135,7 @@ fn serve_file_diff(req: Request, ctx: Context, run_id: Int) -> Response { let qs = wisp.get_query(req) let path = list.key_find(qs, "path") |> result.unwrap("") let ref = list.key_find(qs, "ref") |> result.unwrap("worktree") - let repo_path = - ctx.config.runs_dir <> "/" <> int.to_string(run_id) <> "/wip" + let repo_path = ctx.config.runs_dir <> "/" <> int.to_string(run_id) <> "/wip" case path { "" -> wisp.bad_request("path is required") _ -> diff --git a/src/server/src/fbi/handlers/history.gleam b/src/server/src/fbi/handlers/history.gleam index a4e85a5..32b88f5 100644 --- a/src/server/src/fbi/handlers/history.gleam +++ b/src/server/src/fbi/handlers/history.gleam @@ -34,8 +34,7 @@ fn dispatch(req: Request, ctx: Context, run_id: Int) -> Response { Error(_) -> result_response(history_ops.Invalid(message: "missing op")) Ok(op) -> { case history_mutex.try_acquire(ctx.history_mutex, run_id) { - False -> - result_response(history_ops.Invalid(message: "agent-busy")) + False -> result_response(history_ops.Invalid(message: "agent-busy")) True -> { let outcome = run_op(ctx, run_id, op, body) history_mutex.release(ctx.history_mutex, run_id) @@ -94,8 +93,7 @@ fn run_op( } "sync" -> case run.container_id { - None -> - history_ops.GitError(message: "container not running") + None -> history_ops.GitError(message: "container not running") Some(cid) -> case history_ops.sync_in_container(ctx.config, cid) { Ok(o) -> dispatch_if_conflict(ctx, run, o) @@ -111,14 +109,15 @@ fn run_op( ) decode.success(strat) } - let strategy = case decode.run(body, strat_dec) |> result.unwrap("no-ff") { + let strategy = case + decode.run(body, strat_dec) |> result.unwrap("no-ff") + { "ff-only" -> history_ops.FfOnly "squash" -> history_ops.Squash _ -> history_ops.NoFf } case run.container_id { - None -> - history_ops.GitError(message: "container not running") + None -> history_ops.GitError(message: "container not running") Some(cid) -> case history_ops.merge_in_container( @@ -144,15 +143,11 @@ fn run_op( { history_ops.AgentDispatched(child_id) -> history_ops.Agent(child_run_id: child_id) - history_ops.AgentBusy -> - history_ops.Invalid(message: "agent-busy") - history_ops.DispatchError(m) -> - history_ops.GitError(message: m) + history_ops.AgentBusy -> history_ops.Invalid(message: "agent-busy") + history_ops.DispatchError(m) -> history_ops.GitError(message: m) } "push-submodule" -> - history_ops.Invalid( - message: "submodules not supported in this build", - ) + history_ops.Invalid(message: "submodules not supported in this build") _ -> history_ops.Invalid(message: "unknown op: " <> op) } } @@ -176,10 +171,8 @@ fn dispatch_if_conflict( { history_ops.AgentDispatched(child_id) -> history_ops.Conflict(child_run_id: child_id) - history_ops.AgentBusy -> - history_ops.Invalid(message: "agent-busy") - history_ops.DispatchError(m) -> - history_ops.GitError(message: m) + history_ops.AgentBusy -> history_ops.Invalid(message: "agent-busy") + history_ops.DispatchError(m) -> history_ops.GitError(message: m) } o -> o } diff --git a/src/server/src/fbi/handlers/wip.gleam b/src/server/src/fbi/handlers/wip.gleam index a6a2575..3fa062e 100644 --- a/src/server/src/fbi/handlers/wip.gleam +++ b/src/server/src/fbi/handlers/wip.gleam @@ -154,8 +154,7 @@ fn with_repo_path( case runs_db.get(ctx.db, id) { Error(_) -> wisp.not_found() Ok(run) -> { - let path = - ctx.config.runs_dir <> "/" <> int.to_string(id) <> "/wip" + let path = ctx.config.runs_dir <> "/" <> int.to_string(id) <> "/wip" next(run, path) } } diff --git a/src/server/src/fbi/router.gleam b/src/server/src/fbi/router.gleam index 6dd3cab..a23cd36 100644 --- a/src/server/src/fbi/router.gleam +++ b/src/server/src/fbi/router.gleam @@ -2,8 +2,8 @@ import fbi/context.{type Context} import fbi/handlers/changes as changes_handler import fbi/handlers/config as config_handler import fbi/handlers/github as github_handler -import fbi/handlers/history as history_handler import fbi/handlers/health +import fbi/handlers/history as history_handler import fbi/handlers/listening_ports as listening_ports_handler import fbi/handlers/mcp_servers as mcp_servers_handler import fbi/handlers/projects as projects_handler @@ -71,8 +71,7 @@ pub fn handle(req: Request, ctx: Context) -> Response { changes_handler.handle_changes(req, ctx, id) ["api", "runs", id, "file-diff"] -> changes_handler.handle_file_diff(req, ctx, id) - ["api", "runs", id, "history"] -> - history_handler.handle(req, ctx, id) + ["api", "runs", id, "history"] -> history_handler.handle(req, ctx, id) ["api", "runs", id, "commits", sha, "files"] -> changes_handler.handle_commit_files(req, ctx, id, sha) ["api", "runs", id, "submodule", path, "commits", sha, "files"] -> diff --git a/src/server/test/fbi/git/parse_test.gleam b/src/server/test/fbi/git/parse_test.gleam index 815cf96..ce7d7c5 100644 --- a/src/server/test/fbi/git/parse_test.gleam +++ b/src/server/test/fbi/git/parse_test.gleam @@ -8,7 +8,11 @@ pub fn parse_log_porcelain_two_commits_test() { let parsed = parse.parse_log_porcelain(input) parsed |> should.equal([ - parse.LogEntry(sha: "abc123", subject: "first commit", committed_at: 1_700_000_001), + parse.LogEntry( + sha: "abc123", + subject: "first commit", + committed_at: 1_700_000_001, + ), parse.LogEntry( sha: "de4567", subject: "second commit", @@ -99,10 +103,7 @@ pub fn parse_diff_hunks_multiple_hunks_test() { pub fn parse_diff_hunks_no_newline_marker_test() { let input = - "@@ -1 +1 @@\n" - <> "-a\n" - <> "\\ No newline at end of file\n" - <> "+b\n" + "@@ -1 +1 @@\n" <> "-a\n" <> "\\ No newline at end of file\n" <> "+b\n" let hunks = parse.parse_diff_hunks(input) let assert [hunk] = hunks hunk.lines diff --git a/src/server/test/fbi/git/repo_test.gleam b/src/server/test/fbi/git/repo_test.gleam index 72e22b1..c723962 100644 --- a/src/server/test/fbi/git/repo_test.gleam +++ b/src/server/test/fbi/git/repo_test.gleam @@ -28,8 +28,7 @@ fn seed_bare_repo() -> #(String, String) { let assert Ok(_) = git.run(work, ["branch", "-M", "main"]) let assert Ok(_) = git.run(work, ["remote", "add", "bare", bare]) let assert Ok(_) = git.run(work, ["push", "bare", "main"]) - let assert Ok(_) = - git.run(bare, ["symbolic-ref", "HEAD", "refs/heads/main"]) + let assert Ok(_) = git.run(bare, ["symbolic-ref", "HEAD", "refs/heads/main"]) let assert Ok(sha) = git.run(work, ["rev-parse", "HEAD"]) let sha_trimmed = string.trim(sha) #(bare, sha_trimmed) @@ -81,8 +80,7 @@ pub fn branch_base_ahead_behind_test() { let assert Ok(_) = git.run(work2, ["add", "b.txt"]) let assert Ok(_) = git.run(work2, ["commit", "-m", "feature commit"]) let assert Ok(_) = git.run(work2, ["push", "origin", "feature"]) - let assert Ok(result) = - repo.branch_base_ahead_behind(bare, "feature", "main") + let assert Ok(result) = repo.branch_base_ahead_behind(bare, "feature", "main") result.base |> should.equal("main") result.ahead |> should.equal(1) result.behind |> should.equal(0)