Add read-only git workbench panels
This commit is contained in:
+208
-1
@@ -12,6 +12,7 @@ pub const Error = error{
|
||||
InvalidPattern,
|
||||
InvalidQuery,
|
||||
InvalidSearchRow,
|
||||
InvalidStatusRow,
|
||||
FileNotFound,
|
||||
};
|
||||
|
||||
@@ -199,6 +200,129 @@ fn byteOffsetForLineColumn(content_bytes: []const u8, line_no: usize, column: us
|
||||
return Error.InvalidSearchRow;
|
||||
}
|
||||
|
||||
pub fn gitStatusRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8) ![][]const u8 {
|
||||
const argv = [_][]const u8{ "git", "-C", cwd, "status", "--short" };
|
||||
const result = std.process.run(allocator, io, .{
|
||||
.argv = &argv,
|
||||
.stdout_limit = .limited(64 * 1024),
|
||||
.stderr_limit = .limited(16 * 1024),
|
||||
}) catch |err| return singleOwnedRow(allocator, try std.fmt.allocPrint(allocator, "git_error:{s}", .{@errorName(err)}));
|
||||
defer allocator.free(result.stdout);
|
||||
defer allocator.free(result.stderr);
|
||||
if (!termSucceeded(result.term)) return singleOwnedRow(allocator, try gitErrorRowAlloc(allocator, result.stderr));
|
||||
return parseStatusRowsAlloc(allocator, result.stdout);
|
||||
}
|
||||
|
||||
pub fn gitDiffRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, path: []const u8) ![][]const u8 {
|
||||
try validatePath(path);
|
||||
const argv = [_][]const u8{ "git", "-C", cwd, "diff", "--unified=0", "--", path };
|
||||
const result = std.process.run(allocator, io, .{
|
||||
.argv = &argv,
|
||||
.stdout_limit = .limited(256 * 1024),
|
||||
.stderr_limit = .limited(16 * 1024),
|
||||
}) catch |err| return singleOwnedRow(allocator, try std.fmt.allocPrint(allocator, "git_error:{s}", .{@errorName(err)}));
|
||||
defer allocator.free(result.stdout);
|
||||
defer allocator.free(result.stderr);
|
||||
if (!termSucceeded(result.term)) return singleOwnedRow(allocator, try gitErrorRowAlloc(allocator, result.stderr));
|
||||
return parseDiffRowsAlloc(allocator, result.stdout);
|
||||
}
|
||||
|
||||
pub fn gitPathFromStatusRow(row: []const u8) ![]const u8 {
|
||||
const colon = std.mem.indexOfScalar(u8, row, ':') orelse return Error.InvalidStatusRow;
|
||||
const path = row[colon + 1 ..];
|
||||
if (path.len == 0 or std.mem.startsWith(u8, row, "git_error:") or std.mem.eql(u8, row, "clean")) return Error.InvalidStatusRow;
|
||||
try validatePath(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
pub fn readRepoFileAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, path: []const u8) ![]u8 {
|
||||
try validatePath(path);
|
||||
const full_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ cwd, path });
|
||||
defer allocator.free(full_path);
|
||||
return std.Io.Dir.cwd().readFileAlloc(io, full_path, allocator, .limited(1024 * 1024));
|
||||
}
|
||||
|
||||
fn termSucceeded(term: std.process.Child.Term) bool {
|
||||
return switch (term) {
|
||||
.exited => |code| code == 0,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn parseStatusRowsAlloc(allocator: std.mem.Allocator, stdout: []const u8) ![][]const u8 {
|
||||
var rows = std.ArrayList([]const u8).empty;
|
||||
errdefer {
|
||||
for (rows.items) |row| allocator.free(row);
|
||||
rows.deinit(allocator);
|
||||
}
|
||||
var lines = std.mem.splitScalar(u8, stdout, '\n');
|
||||
while (lines.next()) |raw_line| {
|
||||
const line = std.mem.trim(u8, raw_line, "\r");
|
||||
if (line.len == 0) continue;
|
||||
if (line.len < 4) continue;
|
||||
const code = line[0..2];
|
||||
var path = std.mem.trim(u8, line[3..], " ");
|
||||
if (std.mem.indexOf(u8, path, " -> ")) |rename_arrow| path = path[rename_arrow + 4 ..];
|
||||
try validatePath(path);
|
||||
try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:{s}", .{ statusName(code), path }));
|
||||
}
|
||||
if (rows.items.len == 0) try rows.append(allocator, try allocator.dupe(u8, "clean"));
|
||||
return rows.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn statusName(code: []const u8) []const u8 {
|
||||
if (std.mem.indexOfScalar(u8, code, '?') != null) return "untracked";
|
||||
if (std.mem.indexOfScalar(u8, code, 'D') != null) return "deleted";
|
||||
if (std.mem.indexOfScalar(u8, code, 'A') != null) return "added";
|
||||
if (std.mem.indexOfScalar(u8, code, 'M') != null) return "modified";
|
||||
if (std.mem.indexOfScalar(u8, code, 'R') != null) return "renamed";
|
||||
return "changed";
|
||||
}
|
||||
|
||||
fn parseDiffRowsAlloc(allocator: std.mem.Allocator, stdout: []const u8) ![][]const u8 {
|
||||
var rows = std.ArrayList([]const u8).empty;
|
||||
errdefer {
|
||||
for (rows.items) |row| allocator.free(row);
|
||||
rows.deinit(allocator);
|
||||
}
|
||||
var lines = std.mem.splitScalar(u8, stdout, '\n');
|
||||
while (lines.next()) |raw_line| {
|
||||
const line = std.mem.trim(u8, raw_line, "\r");
|
||||
if (line.len == 0) continue;
|
||||
if (std.mem.startsWith(u8, line, "@@")) {
|
||||
const preview = try previewAlloc(allocator, line);
|
||||
defer allocator.free(preview);
|
||||
try rows.append(allocator, try std.fmt.allocPrint(allocator, "hunk:{s}", .{preview}));
|
||||
} else if (std.mem.startsWith(u8, line, "+") and !std.mem.startsWith(u8, line, "+++")) {
|
||||
const preview = try previewAlloc(allocator, line[1..]);
|
||||
defer allocator.free(preview);
|
||||
try rows.append(allocator, try std.fmt.allocPrint(allocator, "add:{s}", .{preview}));
|
||||
} else if (std.mem.startsWith(u8, line, "-") and !std.mem.startsWith(u8, line, "---")) {
|
||||
const preview = try previewAlloc(allocator, line[1..]);
|
||||
defer allocator.free(preview);
|
||||
try rows.append(allocator, try std.fmt.allocPrint(allocator, "del:{s}", .{preview}));
|
||||
}
|
||||
}
|
||||
if (rows.items.len == 0) try rows.append(allocator, try allocator.dupe(u8, "no diff"));
|
||||
return rows.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn gitErrorRowAlloc(allocator: std.mem.Allocator, stderr: []const u8) ![]u8 {
|
||||
const trimmed = std.mem.trim(u8, stderr, " \t\r\n");
|
||||
if (trimmed.len == 0) return allocator.dupe(u8, "git_error:command_failed");
|
||||
const limit = @min(trimmed.len, 120);
|
||||
const preview = try previewAlloc(allocator, trimmed[0..limit]);
|
||||
defer allocator.free(preview);
|
||||
return std.fmt.allocPrint(allocator, "git_error:{s}", .{preview});
|
||||
}
|
||||
|
||||
fn singleOwnedRow(allocator: std.mem.Allocator, row: []u8) ![][]const u8 {
|
||||
errdefer allocator.free(row);
|
||||
const rows = try allocator.alloc([]const u8, 1);
|
||||
rows[0] = row;
|
||||
return rows;
|
||||
}
|
||||
|
||||
fn matchesPattern(pattern: []const u8, path: []const u8) bool {
|
||||
if (std.mem.endsWith(u8, pattern, "/")) return std.mem.startsWith(u8, path, pattern);
|
||||
if (std.mem.startsWith(u8, pattern, "*.")) return std.mem.endsWith(u8, path, pattern[1..]);
|
||||
@@ -214,7 +338,7 @@ fn freeItems(allocator: std.mem.Allocator, items: [][]const u8) void {
|
||||
allocator.free(items);
|
||||
}
|
||||
|
||||
fn freeOwnedItems(allocator: std.mem.Allocator, items: [][]const u8) void {
|
||||
fn freeOwnedItems(allocator: std.mem.Allocator, items: []const []const u8) void {
|
||||
for (items) |item| allocator.free(item);
|
||||
allocator.free(items);
|
||||
}
|
||||
@@ -324,3 +448,86 @@ test "adversarial: project search rejects bad queries and rows" {
|
||||
try std.testing.expectError(Error.InvalidSearchRow, repo.searchOffsetFromRow("bad-row"));
|
||||
try std.testing.expectError(Error.FileNotFound, repo.searchOffsetFromRow("missing.zig:1:1:needle"));
|
||||
}
|
||||
|
||||
fn makeGitFixture(allocator: std.mem.Allocator) !struct { tmp: std.testing.TmpDir, cwd: []u8 } {
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
errdefer tmp.cleanup();
|
||||
var src = try tmp.dir.createDirPathOpen(std.testing.io, "src", .{});
|
||||
src.close(std.testing.io);
|
||||
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "src/main.zig", .data = "const old = 1;\n" });
|
||||
const cwd = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path});
|
||||
errdefer allocator.free(cwd);
|
||||
try runGitTest(allocator, cwd, &.{ "init", "--quiet" });
|
||||
try runGitTest(allocator, cwd, &.{ "add", "src/main.zig" });
|
||||
try runGitTest(allocator, cwd, &.{ "-c", "user.email=test@example.invalid", "-c", "user.name=Test", "commit", "--quiet", "-m", "init" });
|
||||
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "src/main.zig", .data = "const new = 2;\n" });
|
||||
return .{ .tmp = tmp, .cwd = cwd };
|
||||
}
|
||||
|
||||
fn runGitTest(allocator: std.mem.Allocator, cwd: []const u8, args: []const []const u8) !void {
|
||||
var argv = try allocator.alloc([]const u8, args.len + 3);
|
||||
defer allocator.free(argv);
|
||||
argv[0] = "git";
|
||||
argv[1] = "-C";
|
||||
argv[2] = cwd;
|
||||
@memcpy(argv[3..], args);
|
||||
const result = try std.process.run(allocator, std.testing.io, .{
|
||||
.argv = argv,
|
||||
.stdout_limit = .limited(64 * 1024),
|
||||
.stderr_limit = .limited(64 * 1024),
|
||||
});
|
||||
defer allocator.free(result.stdout);
|
||||
defer allocator.free(result.stderr);
|
||||
try std.testing.expect(termSucceeded(result.term));
|
||||
}
|
||||
|
||||
test "regular: git status rows expose changed files" {
|
||||
var fixture = try makeGitFixture(std.testing.allocator);
|
||||
defer {
|
||||
std.testing.allocator.free(fixture.cwd);
|
||||
fixture.tmp.cleanup();
|
||||
}
|
||||
|
||||
const rows = try gitStatusRowsAlloc(std.testing.allocator, std.testing.io, fixture.cwd);
|
||||
defer freeOwnedItems(std.testing.allocator, rows);
|
||||
try std.testing.expectEqual(@as(usize, 1), rows.len);
|
||||
try std.testing.expectEqualStrings("modified:src/main.zig", rows[0]);
|
||||
try std.testing.expectEqualStrings("src/main.zig", try gitPathFromStatusRow(rows[0]));
|
||||
}
|
||||
|
||||
test "regular: git diff rows expose hunks and changed lines" {
|
||||
var fixture = try makeGitFixture(std.testing.allocator);
|
||||
defer {
|
||||
std.testing.allocator.free(fixture.cwd);
|
||||
fixture.tmp.cleanup();
|
||||
}
|
||||
|
||||
const rows = try gitDiffRowsAlloc(std.testing.allocator, std.testing.io, fixture.cwd, "src/main.zig");
|
||||
defer freeOwnedItems(std.testing.allocator, rows);
|
||||
try std.testing.expect(rows.len >= 3);
|
||||
try std.testing.expect(std.mem.startsWith(u8, rows[0], "hunk:@@"));
|
||||
try std.testing.expect(std.mem.indexOf(u8, rows[1], "const_old_=_1;") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, rows[2], "const_new_=_2;") != null);
|
||||
}
|
||||
|
||||
test "regular: changed git file can be read for editor navigation" {
|
||||
var fixture = try makeGitFixture(std.testing.allocator);
|
||||
defer {
|
||||
std.testing.allocator.free(fixture.cwd);
|
||||
fixture.tmp.cleanup();
|
||||
}
|
||||
|
||||
const content = try readRepoFileAlloc(std.testing.allocator, std.testing.io, fixture.cwd, "src/main.zig");
|
||||
defer std.testing.allocator.free(content);
|
||||
try std.testing.expectEqualStrings("const new = 2;\n", content);
|
||||
}
|
||||
|
||||
test "adversarial: git errors are visible rows and status rows are validated" {
|
||||
const rows = try gitStatusRowsAlloc(std.testing.allocator, std.testing.io, "/definitely/not/a/mim/repo");
|
||||
defer freeOwnedItems(std.testing.allocator, rows);
|
||||
try std.testing.expectEqual(@as(usize, 1), rows.len);
|
||||
try std.testing.expect(std.mem.startsWith(u8, rows[0], "git_error:"));
|
||||
try std.testing.expectError(Error.InvalidStatusRow, gitPathFromStatusRow(rows[0]));
|
||||
try std.testing.expectError(Error.InvalidStatusRow, gitPathFromStatusRow("clean"));
|
||||
try std.testing.expectError(Error.InvalidPath, gitPathFromStatusRow("modified:../secret"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user