shgit is a Zig CLI tool for managing personal project overlays with git. It allows storing project-specific files (configs, env templates) that can't be committed to the target repo but should be git-versioned in a private repo.
zig build # Build the project
zig build -Dcross=true -Doptimize=ReleaseFast # Compile for target systems
zig build -Doptimize=ReleaseFast # Build with release optimizations
zig build run -- <args> # Run the CLI
zig build test # Run all tests
zig build test -Dtest-filter="pattern" # Run single test by name filtershgit/
src/
main.zig # Entry point, CLI parsing with zig-clap
config.zig # Config loading/saving, shgit structure
git.zig # Git command wrappers
fs_utils.zig # Filesystem utilities (relative path calc)
commands/
clone.zig # shgit clone command
link.zig # shgit link command
worktree.zig # shgit worktree add/remove/list
sync.zig # shgit sync command
init.zig # shgit init command
test/
main.zig # Integration tests
build.zig # Build configuration
build.zig.zon # Package manifest with dependencies
Order: 1) std library 2) External deps (e.g., clap) 3) Local modules (relative imports)
const std = @import("std");
const clap = @import("clap");
const config = @import("../config.zig");Use scoped logging for all modules:
const log = std.log.scoped(.module_name);
log.err("critical error: {}", .{err});
log.warn("warning message", .{});
log.info("informational message", .{});
log.debug("debug details", .{});- Return errors explicitly, don't panic
- Use
errdeferfor cleanup on error paths - Log errors with context before returning
pub fn doSomething() !void {
const resource = try allocate();
errdefer resource.deinit();
doWork() catch |err| {
log.err("work failed: {}", .{err});
return err;
};
}- Always pass allocator as first parameter
- Use
deferfor cleanup in success path,errdeferfor error path - Prefer
.emptyinitialization for ArrayLists in Zig 0.15+
var list: std.ArrayList([]const u8) = .empty;
defer list.deinit(allocator);
try list.append(allocator, item);- Functions:
camelCase - Types:
PascalCase - Constants:
SCREAMING_SNAKE_CASEfor comptime,snake_casefor runtime - Files:
snake_case.zig - Scopes for logging:
.snake_case
var buf: [4096]u8 = undefined;
var file_writer = file.writer(&buf);
const writer = &file_writer.interface;
try writer.writeAll("content");
try writer.print("{s}\n", .{value});
try writer.flush();- All tests go in the
test/folder, NOT in source files - Tests import the shgit module via
const shgit = @import("shgit") - Unit tests for specific modules go in
test/main.zig - Integration tests also go in
test/main.zig - The
src/root.zigfile exports all modules for testing - The
test/main.zigfile must include:const std = @import("std"); const shgit = @import("shgit"); test { std.testing.refAllDeclsRecursive(@This()); }
- Use
std.testing.allocatorfor memory leak detection - Access modules via
shgit.module_name(e.g.,shgit.git,shgit.config,shgit.fs_utils)
Arguments are parsed using zig-clap's parseParamsComptime in src/main.zig:
const params = comptime clap.parseParamsComptime(
\\-h, --help Display this help and exit.
\\-n, --name <str> Custom name (optional).
\\<str> Required positional argument.
\\<str>... Multiple positional arguments.
\\
);
var diag = clap.Diagnostic{};
var res = clap.parseEx(clap.Help, ¶ms, clap.parsers.default, &iter, .{
.diagnostic = &diag,
.allocator = gpa,
}) catch |err| {
reportDiagnostic(&diag, err);
return err;
};
defer res.deinit();Access parsed args:
- Flags:
res.args.help(count, 0 if not set) - Options:
res.args.name(optional,?[]const u8) - Positionals:
res.positionals[0](tuples indexed from 0) - Subcommands: Use
terminating_positionaloption and enum parsing
Config stored in .shgit/config.json:
{
"main_repo": "reponame",
"sync_enabled": true,
"sync_patterns": [
{
"pattern": ".env",
"mode": "symlink"
},
{
"pattern": ".env.local",
"mode": "copy"
}
]
}Each command defines its argument struct and exports an execute function:
pub const CommandArgs = struct {
option: ?[]const u8 = null,
required_arg: []const u8,
};
pub fn execute(allocator: std.mem.Allocator, args: CommandArgs, verbose: bool) !void {
const shgit_root = try config.findShgitRoot(allocator) orelse {
log.err("not in a shgit project", .{});
return error.NotShgitProject;
};
defer allocator.free(shgit_root);
}Use git.zig wrapper for git commands:
try git.init(allocator, path);
try git.addSubmodule(allocator, cwd, url, subpath);
try git.addWorktree(allocator, repo_path, worktree_path, branch);Use relative paths for symlinks and add to local git exclude:
const rel_link = try fs_utils.relativePath(allocator, target_file, link_file);
defer allocator.free(rel_link);
try std.fs.cwd().symLink(rel_link, target_file, .{});
try git.addLocalExclude(allocator, repo_path, rel_path);- zig-clap: CLI argument parsing
- Add:
zig fetch --save https://github.com/Hejsil/zig-clap/archive/refs/tags/0.11.0.tar.gz
- Add:
- ArrayList in Zig 0.15 uses
.emptyinit, not.init(allocator) - File.writer() requires a buffer parameter
- Use
&writer.interfaceto get the actual writer interface - For error reporting with clap, use the
reportDiagnostichelper function - Subcommands use
terminating_positionalto parse first positional as enum
IMPORTANT: After making any code changes, always build and run tests to ensure everything still works:
zig build # Verify the project builds
zig build test # Run all tests to catch regressions