diff --git a/.gitignore b/.gitignore index 8359292..3389c86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,2 @@ -*.o -XD -compile_commands.json -.cache +.zig-cache/ +zig-out/ diff --git a/Makefile b/Makefile deleted file mode 100644 index 66be544..0000000 --- a/Makefile +++ /dev/null @@ -1,24 +0,0 @@ -include config.mk - -# flags and incs -PKGS = $(GITLIB) -CFLAGS = -DVERSION=\"$(VERSION)\" -Wall -pedantic -O3 $(GIT) $(GITHASH) $(ERR) $(PERF) $(EXPLAIN) -LIBS = `$(PKG_CONFIG) --libs --cflags $(PKGS)` - -all: XD -XD: XD.o hash.o helpers.o - $(CC) *.o $(CFLAGS) $(LIBS) -o $@ - -clean: - rm -f XD *.o - -install: XD - mkdir -p $(PREFIX)/bin - cp -f XD $(PREFIX)/bin - chmod 755 $(PREFIX)/bin/XD - mkdir -p $(MANDIR)/man1 - cp -f XD.1 $(MANDIR)/man1 - chmod 644 $(MANDIR)/man1/XD.1 - -uninstall: XD - rm -f $(PREFIX)/bin/XD $(MANDIR)/man1/XD.1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b98cbc --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# XD +Smiley face to explain info on the shell. +--- +More info on XD can be found in the manpage. + +## Building +When packaging XD it's recommended to use the following flags: +`zig build -Doptimize=ReleaseFast` +which will won't be linked to libgit2, if you instead wish to link to libgit2: +`zig build -Doptimize=ReleaseFast -Ddynamic=true` +Statically linking results in marginally faster, but significantly larger +executables. diff --git a/XD.1 b/XD.1 index d7a147d..3203ff1 100644 --- a/XD.1 +++ b/XD.1 @@ -28,6 +28,7 @@ c;l. \;;in a git repo 8;in a git repo with stashed changes X;in a git repo during a merge + O;in a git repo during a rebase B;in a git repo with no commits .TE .Ss Nose diff --git a/XD.c b/XD.c deleted file mode 100644 index de8d694..0000000 --- a/XD.c +++ /dev/null @@ -1,397 +0,0 @@ -#include -#include -#include -#include -#include -#include - -#ifdef ERR -#include -#endif -#if defined(EXPLAIN) || defined(ERR) -#include -#endif - -#ifdef GIT -#include -#include -#include -#endif - -#include "helpers.h" -#include "hash.h" - -#define P(X) fwrite(X, 1, 1, stdout) - -#ifdef GIT -/** - * @brief search all parent directories for a git repo - * - * @return absolute path to git repo - */ -char -*find_git_repo(void) -{ - PS(); - char path[PATH_MAX] = ".", fstr[PATH_MAX], *rpath, *res; - struct stat s; - FILE *f; - int i, c; - - /* find the number of jumps to the root of the fs */ - rpath = realpath(path, NULL); - if (!rpath) { - L("realpath: %s", strerror(errno)); - PE(); - return NULL; - } - for (i = c = 0; i < strlen(rpath); i++) { - if (rpath[i] == '/') { - c++; - } - } - free(rpath); - - /* start searching */ - for (i = c; i > 0; i--) { - strcat(path, "/.git"); - - /* if there seems to be a git directory return the directory it was found in */ - if (stat(path, &s) == 0) { - if (S_ISDIR(s.st_mode)) { - PE(); - return realpath(path, NULL); - } else if (S_ISREG(s.st_mode)) { - /* we do some special magic here to check if we're in a submodule */ - f = fopen(path, "r"); - if (!f) { - L("fopen: %s", strerror(errno)); - PE(); - return NULL; - } - res = fgets(fstr, PATH_MAX, f); - fclose(f); - if (!res) { - L("fgets: %s", strerror(errno)); - PE(); - return NULL; - } - if (strncmp(fstr, "gitdir: ", strlen("gitdir: ")) == 0) { - fstr[strlen(fstr) - 1] = '\0'; - PE(); - return realpath(fstr + strlen("gitdir: "), NULL); - } - } - } - - /* reset contents of gpath, and go up a directory */ - memset(&path[strlen(path) - 4], '.', 2); - memset(&path[strlen(path) - 2], 0, 2); - } - - PE(); - return NULL; -} - -/** - * @brief open git repo if one is available at the current path - * - * @return a pointer to the git repo object - */ -git_repository -*init_git(void) -{ - PS(); - char *buf; - git_repository *repo; - - /* check for a repo before loading libgit2 */ - if ((buf = find_git_repo()) == NULL) { - PE(); - return NULL; - } - - /* disable a bunch of git options to hopefully speed things up */ - git_libgit2_opts(GIT_OPT_ENABLE_CACHING, 0); - git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_GLOBAL, ""); - git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_XDG, ""); - git_libgit2_opts(GIT_OPT_SET_SEARCH_PATH, GIT_CONFIG_LEVEL_SYSTEM, ""); - git_libgit2_opts(GIT_OPT_SET_TEMPLATE_PATH, ""); - git_libgit2_opts(GIT_OPT_DISABLE_PACK_KEEP_FILE_CHECKS, 1); - git_libgit2_opts(GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION, 0); - - /* initialize the git library and repository */ - if (git_libgit2_init() < 0) { - L("Failed to initalize libgit2, proceeding without git functionality enabled."); - PE(); - return NULL; - } - - if (git_repository_open(&repo, buf) < 0) { - L("Failed to open git repo: %s", git_error_last()->message); - free(buf); - PE(); - return NULL; - } - - /* get rid of object containing git repo path and return the repo */ - free(buf); - PE(); - return repo; -} - -/** - * @brief check for any existing stashes in the provided git repo - * - * @param repo git repo to check for existing stashes - * @return 1 if stashes found 0 otherwise - */ -int -has_stashes(git_repository *repo) -{ - PS(); - git_reference *stash = NULL; - int e; - - e = git_reference_lookup(&stash, repo, "refs/stash"); - if (e == GIT_ENOTFOUND) { - PE(); - return 0; - } else if (e < 0) { - L("Error looking up stash reference: %s", git_error_last()->message); - PE(); - return 0; - } else { - e = 1; - } - - git_reference_free(stash); - PE(); - return e; -} - -/** - * @brief check for any untracked changes in the current git repo - * - * @param repo git repository object - * @return 1 if any untracked changes have been found 0 otherwise - */ -int -has_untracked(git_repository *repo) -{ - PS(); - git_status_options opts = GIT_STATUS_OPTIONS_INIT; - git_status_list *list = NULL; - repohash *storedhash; - uint64_t newhash = generate_hash(repo); - int r = 0; - - #ifdef GITHASH - if ((storedhash = read_hash(repo)) - && storedhash->hash == newhash) { - r = storedhash->changes; - free(storedhash); - PE(); - return r; - } - #endif - - /* if we need to regen the hash then we need to do a hard check on the real - * git repository */ - opts.show = GIT_STATUS_SHOW_INDEX_AND_WORKDIR; - opts.flags = GIT_STATUS_OPT_INCLUDE_UNTRACKED | - GIT_STATUS_OPT_RENAMES_HEAD_TO_INDEX | - GIT_STATUS_OPT_UPDATE_INDEX | - GIT_STATUS_OPT_DISABLE_PATHSPEC_MATCH; - - if (git_status_list_new(&list, repo, &opts) < 0) { - L("Error checking for untracked changes: %s", git_error_last()->message); - PE(); - return 0; - } - - /* if any changes are found return 1 */ - r = git_status_list_entrycount(list) > 0; - #ifdef GITHASH - write_hash(repo, (repohash){ .hash = newhash, .changes = r }); - #endif - - git_status_list_free(list); - PE(); - return r; -} - -/** - * @brief check for staged changes - * - * @param repo git repository object - * @return 1 if any staged changes found 0 otherwise - */ -int -has_staged(git_repository *repo) -{ - PS(); - git_status_entry entry; - git_status_list *list = NULL; - git_status_options opts = GIT_STATUS_OPTIONS_INIT; - int i, c, r = 0; - - opts.show = GIT_STATUS_SHOW_INDEX_ONLY; - opts.flags = GIT_STATUS_INDEX_NEW; - - if (git_status_list_new(&list, repo, &opts) < 0) { - L("Error checking for staged changes: %s", git_error_last()->message); - PE(); - return 0; - } - - /* if any staged changes are found return 1 */ - if ((c = git_status_list_entrycount(list)) > 0) { - for (i = 0; i < c; i++) { - entry = *git_status_byindex(list, i); - - if (entry.status & (GIT_STATUS_INDEX_NEW - | GIT_STATUS_INDEX_DELETED - | GIT_STATUS_INDEX_MODIFIED)) { - r = 1; - break; - } - } - } - - git_status_list_free(list); - PE(); - return r; -} -#endif - -inline unsigned -numcat(unsigned x, unsigned y) -{ - PS(); - unsigned pow = 10; - while(y >= pow) { - pow *= 10; - } - - PE(); - return (x * pow) + y; -} - -int -str_to_int(char *str) -{ - PS(); - int res = -1; - char *c; - - for (c = str; (*c != '\0') && isdigit(*c); c++) { - if (res == -1) { - res = *c - '0'; - } else { - res = numcat(res, *c - '0'); - } - } - - PE(); - return res; -} - -int -main(int argc, char *argv[]) -{ - PS(); - int code = -1; - - /* print version information */ - if (argc > 1 && strcmp(argv[1], "-v") == 0) { - printf("XD [number] %s\n", VERSION); - PE(); - return 0; - #ifdef EXPLAIN - } else if (argc > 1 && strcmp(argv[1], "-e") == 0) { - explain = 1; - #endif - } - - #ifdef GIT - git_repository *repo; - - if ((repo = init_git())) { - /* change the eyes depending on the current git repo's status */ - if (has_stashes(repo)) { - E("The current git repo has stashed changes.") - P("8"); /* goggle eyes if we have some stashed changes */ - } else if (git_repository_state(repo) == GIT_REPOSITORY_STATE_MERGE) { - E("The current git repo is in the middle of a merge.") - P("X"); /* laughing eyes cause the user is fucked */ - } else if (git_repository_is_empty(repo)) { - E("This is a new git repo.") - P("B"); /* sunglasses if we're in a new repo with no HEAD */ - } else { - E("In a git repository.") - P(";"); /* wink when we're in a git repo */ - } - - /* change the nose depending on the current git repo's status */ - if (has_staged(repo)) { - E("There staged changes.") - P("*"); /* change to broken nose for staged changes */ - } else if (has_untracked(repo)) { - E("There are untracked changes in the repository.") - P("^"); /* add a little nose when there are untracked changes in the repo */ - } else if (git_repository_head_detached(repo)) { - E("The HEAD is detached.") - P("-"); /* add a minus nose when the HEAD is detached */ - } - git_repository_free(repo); - git_libgit2_shutdown(); - } else - #endif - if (1) { - E("Not in a git repository.") - P(":"); - } - - /* get exit code from user args */ - if (argc > 1 && argv[argc - 1]) { - code = str_to_int(argv[argc - 1]); - if (code < 0) { - L("Return code, %d, not valid", code); - PE(); - exit(1); - } - } - - /* change mouth based on exit code */ - if (code >= 0) { - switch (code) { - case 0: /* all good */ - E("Return code of %d, no errors.", code) - P(")"); - break; - case 130: /* Ctrl-c pressed (SIGTERM) */ - E("Return code of %d, ctrl-c was pressed, or SIGTERM sent.", code) - P("O"); - break; - case 126: /* permission denied */ - E("Return code of %d, permission denied.", code) - P("P"); - break; - case 127: /* command not found */ - E("Return code of %d, command not found.", code) - P("/"); - break; - default: /* all other codes (usually the program saying it has failed) */ - E("Return code of %d, probably an error with the program.", code) - P("("); - break; - } - } else { - E("No code information passed in.") - P("|"); /* no code info */ - } - - PE(); - return code; -} diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..04dde83 --- /dev/null +++ b/build.zig @@ -0,0 +1,67 @@ +const std = @import("std"); +const pkg = @import("build.zig.zon"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const git = b.option(bool, "git", "Enable git support") orelse true; + const explain = b.option(bool, "explain", "Explain what the emojitcon means") orelse true; + const perf = b.option(bool, "perf", "Monitor performance of functions") orelse false; + const err = b.option(bool, "err", "Print errors") orelse false; + const dynamic = b.option(bool, "dynamic", "Link XD dynamically") orelse false; + const cleanup = b.option(bool, "cleanup", "removes the old XDhash file from your git directories") orelse false; + + const options = b.addOptions(); + options.addOption(bool, "git", git); + options.addOption(bool, "explain", explain); + options.addOption(bool, "perf", perf); + options.addOption(bool, "err", err); + options.addOption(bool, "dynamic", dynamic); + options.addOption(bool, "cleanup", cleanup); + options.addOption([]const u8, "version", pkg.version); + + var mod = b.addModule("XD", .{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + const exe = b.addExecutable(.{ .name = "XD", .root_module = mod }); + b.installArtifact(exe); + + // add our buildtime options as a module called config + exe.root_module.addOptions("config", options); + + if (git) { + if (dynamic) { + exe.root_module.linkSystemLibrary("git2", .{}); + mod.link_libc = true; + } else { + // not sure if there's an easier way to do this, there's a type in + // the libgit2 bindings build.zig but I'm not sure how to access it + const libgit2_dep = if (optimize == .Debug) b.dependency("libgit2", .{ + .target = target, + .optimize = optimize, + .@"enable-ssh" = false, + .@"tls-backend" = .mbedtls, // openssl doesn't work in debug mode + }) else b.dependency("libgit2", .{ + .target = target, + .optimize = optimize, + .@"enable-ssh" = false, + .@"tls-backend" = .openssl, // much much faster than mbedtls + }); + exe.linkLibrary(libgit2_dep.artifact("git2")); + } + } + + const run_step = b.step("run", "Run the app"); + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + + const tests = b.addTest(.{ .root_module = mod }); + const test_step = b.step("test", "Run tests"); + const test_cmd = b.addRunArtifact(tests); + test_step.dependOn(&test_cmd.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..fae630b --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,19 @@ +.{ + .name = .XD, + .version = "6.0.0", + .fingerprint = 0x420a402a481c77c1, // Changing this has security and trust implications. + .minimum_zig_version = "0.15.2", + .dependencies = .{ + .libgit2 = .{ + .url = "git+https://github.com/allyourcodebase/libgit2#58dfd002d47a8c9fbd99d4a939cb343172590e1b", + .hash = "libgit2-1.9.0-uizqTQnZAADyPOmBklxzj_lrnfJoRLRDBbbTvi28SrZX", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "LICENSE", + "README.md", + }, +} diff --git a/config.mk b/config.mk deleted file mode 100644 index 2614a75..0000000 --- a/config.mk +++ /dev/null @@ -1,54 +0,0 @@ -VERSION := `git describe --tags --dirty` - -PKG_CONFIG = pkg-config - -# paths -PREFIX = /usr/local -MANDIR = $(PREFIX)/share/man - -GIT = -GITHASH = -GITLIB = -# comment to disable git support -GIT = -DGIT -GITHASH = -DGITHASH -GITLIB = libgit2 - -ERR = -# uncomment to enable errors -# ERR = -DERR - -PERF = -# uncomment to enable performance logging -# PERF = -DPERF - -EXPLAIN = -# comment to disable explinations -EXPLAIN = -DEXPLAIN - -# add compilation details to VERSION variable -ifneq ($(GIT),) - VERSION := $(VERSION)"\\nlibgit2 "`$(PKG_CONFIG) --modversion $(GITLIB)` -endif -ifeq ($(GITHASH),) - VERSION := $(VERSION)"\\ngit hashing disabled" -else - VERSION := $(VERSION)"\\ngit hashing enabled" -endif -ifeq ($(EXPLAIN),) - VERSION := $(VERSION)"\\nexplinations disabled" -else - VERSION := $(VERSION)"\\nexplinations enabled" -endif -ifeq ($(ERR),) - VERSION := $(VERSION)"\\nerrors disabled" -else - VERSION := $(VERSION)"\\nerrors enabled" -endif -ifeq ($(PERF),) - VERSION := $(VERSION)"\\nperformance logging disabled" -else - VERSION := $(VERSION)"\\nperformance logging enabled" -endif - -CC = cc diff --git a/hash.c b/hash.c deleted file mode 100644 index 5403d50..0000000 --- a/hash.c +++ /dev/null @@ -1,152 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -#include "hash.h" -#include "helpers.h" - -#ifdef GITHASH -static uint64_t -murmur64(uint64_t k) -{ - PS(); - k ^= k >> 33; - k *= 0xff51afd7ed558ccdLLU; - k ^= k >> 33; - k *= 0xc4ceb9fe1a85ec53LLU; - k ^= k >> 33; - - PE(); - return k; -} - -uint64_t -generate_hash(git_repository *repo) -{ - PS(); - struct stat dir, index; - char path[PATH_MAX] = { 0 }; - - const char *gitpath = git_repository_path(repo); - if (strlen(gitpath) + strlen("/..") > PATH_MAX - 1) { - L("strlen: %s", strerror(errno)); - PE(); - return -1; - } - - strcat(path, gitpath); - if (stat(path, &index) < 0) { - PE(); - return -1; - } - strcat(path, "/.."); - if (stat(path, &dir) < 0) { - PE(); - return -1; - } - PE(); - return murmur64(dir.st_mtim.tv_nsec ^ index.st_mtim.tv_nsec); -} - -repohash -*read_hash(git_repository *repo) -{ - PS(); - FILE *f; - uint64_t data; - uint8_t changes; - repohash *hash = malloc(sizeof(repohash)); - char path[PATH_MAX] = { 0 }; - - const char *gitpath = git_repository_path(repo); - if (strlen(gitpath) + strlen(XD_HASH_PATH) > PATH_MAX - 1) { - L("strlen: %s", strerror(errno)); - PE(); - return NULL; - } - strcat(path, gitpath); - strcat(path, XD_HASH_PATH); - - f = fopen(path, "r"); - if (!f) { - L("fopen: %s", strerror(errno)); - PE(); - return NULL; - } - - if (fread(&data, sizeof(uint64_t), 1, f) != 1) { - L("fread: %s", strerror(errno)); - PE(); - return NULL; - } - - if (fseek(f, sizeof(uint64_t), SEEK_SET) < 0) { - L("fseek: %s", strerror(errno)); - PE(); - return NULL; - } - - if (fread(&changes, sizeof(uint8_t), 1, f) != 1) { - L("fread: %s", strerror(errno)); - PE(); - return NULL; - } - fclose(f); - - hash->hash = data; - hash->changes = changes; - PE(); - return hash; -} - -int -write_hash(git_repository *repo, repohash hash) -{ - PS(); - FILE *f; - char path[PATH_MAX] = { 0 }; - - const char *gitpath = git_repository_path(repo); - if (strlen(gitpath) + strlen(XD_HASH_PATH) > PATH_MAX - 1) { - L("strlen: %s", strerror(errno)); - PE(); - return 1; - } - strcat(path, gitpath); - strcat(path, XD_HASH_PATH); - - f = fopen(path, "wb"); - if (!f) { - L("fopen: %s", strerror(errno)); - PE(); - return 1; - } - - if (fwrite(&hash.hash, sizeof(uint64_t), 1, f) != 1) { - L("fwrite: %s", strerror(errno)); - PE(); - return 1; - } - - if (fseek(f, sizeof(uint64_t), SEEK_SET) < 0) { - L("fseek: %s", strerror(errno)); - PE(); - return 1; - } - - if (fwrite(&hash.changes, sizeof(uint8_t), 1, f) != 1) { - L("fwrite: %s", strerror(errno)); - PE(); - return 1; - } - - fclose(f); - PE(); - return 0; -} -#endif diff --git a/hash.h b/hash.h deleted file mode 100644 index a78cd99..0000000 --- a/hash.h +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once - -#include -#include -#include - -#ifdef GIT -typedef struct { - uint64_t hash; - bool changes; -} repohash; - -#define XD_HASH_PATH "/XDhash" -/** - * @brief generate a hash from the repository state - * - * @param repo the git repository - * @return the hash - */ -uint64_t generate_hash(git_repository *repo); - -/** - * @brief read the hash from the git repo - * - * @param repo the git repository - * @return the hash - */ -repohash *read_hash(git_repository *repo); - -/** - * @brief write a new hash to the repository - * - * @param repo the repository - * @param hash the hash to write - */ -int write_hash(git_repository *repo, repohash hash); -#endif diff --git a/helpers.c b/helpers.c deleted file mode 100644 index e3ef987..0000000 --- a/helpers.c +++ /dev/null @@ -1,28 +0,0 @@ -#include -#include -#include - -#include "helpers.h" - -#ifdef EXPLAIN -int explain = 0; -#endif - -#if defined(ERR) || defined(EXPLAIN) -void -l(const char *fmt, ...) -{ - va_list ap; - - va_start(ap, fmt); - vfprintf(stderr, fmt, ap); - va_end(ap); - - if (fmt[0] && fmt[strlen(fmt) - 1] == ':') { - fputc(' ', stderr); - perror(NULL); - } else { - fputc('\n', stderr); - } -} -#endif diff --git a/helpers.h b/helpers.h deleted file mode 100644 index b106667..0000000 --- a/helpers.h +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#if defined(ERR) || defined(EXPLAIN) -void l(const char *fmt, ...); -#endif - -#ifdef ERR -#define L(...) l(__VA_ARGS__) -#else -#define L(...) -#endif - -#ifdef EXPLAIN -extern int explain; -#define E(...) if (explain) { \ - l(__VA_ARGS__); \ - } else -#else -#define E(...) -#endif - -#ifdef PERF -#define PS() long __start = clock() -#define PE() l("%s: %fs", __func__, ((double) (clock() - __start)) / CLOCKS_PER_SEC) -#else -#define PS() -#define PE() -#endif diff --git a/src/Cache.zig b/src/Cache.zig new file mode 100644 index 0000000..33ec661 --- /dev/null +++ b/src/Cache.zig @@ -0,0 +1,305 @@ +const Cache = @This(); + +const std = @import("std"); +const config = @import("config"); +const util = @import("util.zig"); +const perf = @import("perf.zig"); + +/// The cache file name +const file_name = "XDcache"; + +/// this is previous name of the file that I would like to remove if we +/// encounter it +const old_file_name = "XDhash"; + +/// Storage of features and version data +pub const Data = struct { + untracked_file_paths: struct { + data: []const []const u8, + comptime version: std.SemanticVersion = .{.major = 1, .minor = 0, .patch = 0}, + }, + + const new_entry_marker = ":)"; + const version: std.SemanticVersion = .{.major = 1, .minor = 0, .patch = 0}; + + /// Generate an enum of features at comptime + fn Features() type { + const lib_fields = @typeInfo(Data).@"struct".fields; + var fields: [lib_fields.len]std.builtin.Type.EnumField = undefined; + for (lib_fields, 0..) |f, i| fields[i] = .{ .name = f.name, .value = i }; + + return @Type(.{.@"enum" = .{ + .decls = &.{}, + .tag_type = i16, + .fields = &fields, + .is_exhaustive = true, + }}); + } +}; +const Features = Data.Features(); + +allocator: std.mem.Allocator, +path: []const u8, + +pub fn init(allocator: std.mem.Allocator, path: []const u8) Cache { + const s = perf.s(); + defer perf.e(s, @src()); + + const self: Cache = .{ + .allocator = allocator, + .path = path, + }; + + if (config.cleanup) removeOldFile(self); + + return self; +} + +/// remove the old cache file from the git directory +fn removeOldFile(self: *Cache) void { + const delete_path = std.fs.path.join( + self.allocator, + &[_][]const u8{ self.path, old_file_name }, + ) catch return; + defer self.allocator.free(delete_path); + std.fs.deleteFileAbsolute(delete_path) catch return; +} + +pub const ReadError = error{ EmptyFile }; +pub fn readFromFile(self: *Cache) !Data { + const s = perf.s(); + defer perf.e(s, @src()); + + const path = try std.fs.path.join( + self.allocator, + &[_][]const u8{ self.path, file_name }, + ); + defer self.allocator.free(path); + + const file = try std.fs.openFileAbsolute(path, .{.mode = .read_only}); + defer file.close(); + + var file_buffer: [std.fs.max_path_bytes]u8 = undefined; + var file_reader = file.reader(&file_buffer); + const reader: *std.Io.Reader = &file_reader.interface; + if (try file.getEndPos() <= try file.getPos()) { + return ReadError.EmptyFile; + } + + return try read(self.allocator, reader); +} + +fn read(allocator: std.mem.Allocator, reader: *std.Io.Reader) !Data { + const s = perf.s(); + defer perf.e(s, @src()); + + var data: Data = .{ + .untracked_file_paths = undefined, + }; + + var feature: ?Features = null; + var feature_len: u16 = 0; + var untracked_file_paths: ?[][]const u8 = null; + + var i: u32 = 0; + while (reader.takeDelimiterInclusive('\n') catch null) |line| { + if (std.mem.eql(u8, line[0..2], Data.new_entry_marker)) { + i = 0; + feature = null; // we need to determine the feature + const version_end_idx = std.mem.indexOf(u8, line, " "); + if (version_end_idx == null) continue; // not found + const feature_version = try std.SemanticVersion.parse(line[2..version_end_idx.?]); + if (Data.version.order(feature_version) == .lt) { + // unsupported feature + continue; + } + + var feature_name_end_idx = std.mem.indexOf(u8, line[version_end_idx.? + 1..], " "); + if (feature_name_end_idx == null) continue; // not found + feature_name_end_idx.? += version_end_idx.? + 1; // make the idx relative to the start of the line + + const feature_name = line[version_end_idx.? + 1..feature_name_end_idx.?]; + feature_len = try std.fmt.parseInt(u16, line[feature_name_end_idx.? + 1..line.len - 1], 10); + + inline for (@typeInfo(Features).@"enum".fields) |field| { + if (std.mem.eql(u8, field.name, feature_name)) { + feature = @enumFromInt(field.value); + } + } + continue; // skip to the next line with the actual data + } + + if (feature == null) continue; + switch (feature.?) { + .untracked_file_paths => { + if (untracked_file_paths == null) { + untracked_file_paths = try allocator.alloc([]const u8, feature_len); + } + untracked_file_paths.?[i] = line; + }, + } + + i += 1; + } + + data.untracked_file_paths = .{ + .data = untracked_file_paths.?, + }; + return data; +} + +pub fn writeToFile(self: *Cache, data: Data) !void { + const s = perf.s(); + defer perf.e(s, @src()); + + const path = try std.fs.path.join( + self.allocator, + &[_][]const u8{ self.path, file_name }, + ); + defer self.allocator.free(path); + + const file = try std.fs.createFileAbsolute(path, .{ .truncate = true }); + defer file.close(); + + var file_buffer: [std.fs.max_path_bytes]u8 = undefined; + var file_writer = file.writer(&file_buffer); + const writer: *std.io.Writer = &file_writer.interface; + + return try write(writer, data); +} + +fn write(writer: *std.Io.Writer, data: Data) !void { + const s = perf.s(); + defer perf.e(s, @src()); + + inline for (@typeInfo(Data).@"struct".fields) |field| { + const feature = @field(data, field.name); + const feature_enum = @field(Features, field.name); + const feature_version: std.SemanticVersion = @field(feature, "version"); + + // write feature string + try writer.writeAll(Data.new_entry_marker); + try feature_version.format(writer); + try writer.writeAll(" " ++ field.name ++ " "); + try writer.print("{}", .{ feature.data.len }); + try writer.writeAll("\n"); + switch (feature_enum) { + .untracked_file_paths => { + for (feature.data) |d| { + if (std.mem.eql(u8, d, "")) break; + try writer.writeAll(d); + try writer.writeAll("\n"); + } + }, + } + } + + try writer.flush(); +} + +test "write" { + var buf: [std.fs.max_path_bytes]u8 = undefined; + const file = try std.fs.cwd().createFile( + "junk_file.txt", + .{ .read = true }, + ); + defer std.fs.cwd().deleteFile("junk_file.txt") catch undefined; + + var file_writer = file.writer(&buf); + const writer: *std.Io.Writer = &file_writer.interface; + + var file_reader = file.reader(&buf); + const reader: *std.Io.Reader = &file_reader.interface; + + const expected_message = ":)1.0.0 untracked_file_paths 1\ntest\n"; + try Cache.write(writer, .{ + .untracked_file_paths = .{ .data = &[_][]const u8{ "test" } }, + }); + + const contents = try reader.readAlloc( + std.testing.allocator, + expected_message.len, + ); + defer std.testing.allocator.free(contents); + + std.testing.expect(std.mem.eql(u8, contents, expected_message)) catch |err| { + std.log.err("got: \n{s}", .{ contents }); + std.log.err("expected: \n{s}", .{ expected_message }); + return err; + }; +} + +test "read" { + var arena_alloc: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_alloc.deinit(); + const allocator = arena_alloc.allocator(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const file = try std.fs.cwd().createFile( + "junk_file.txt", + .{ .read = true }, + ); + defer std.fs.cwd().deleteFile("junk_file.txt") catch undefined; + + var file_writer = file.writer(&buf); + const writer: *std.Io.Writer = &file_writer.interface; + + var file_reader = file.reader(&buf); + const reader: *std.Io.Reader = &file_reader.interface; + + const expected_message = ":)1.0.0 untracked_file_paths 1\ntest\n"; + try writer.writeAll(expected_message); + try writer.flush(); + + const data = try Cache.read(allocator, reader); + try std.testing.expectEqualDeep(Data { + .untracked_file_paths = .{ .data = &[_][]const u8{ "test\n" } }, + }, data); +} + +test "read long cache" { + var arena_alloc: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_alloc.deinit(); + const allocator = arena_alloc.allocator(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const file = try std.fs.cwd().createFile( + "junk_file.txt", + .{ .read = true }, + ); + defer std.fs.cwd().deleteFile("junk_file.txt") catch undefined; + + var file_writer = file.writer(&buf); + const writer: *std.Io.Writer = &file_writer.interface; + + var file_reader = file.reader(&buf); + const reader: *std.Io.Reader = &file_reader.interface; + + const expected_message = ":)1.0.0 untracked_file_paths 10\n" + ++ "test\n" + ++ "test/a/really\n" + ++ "test/a/really/really\n" + ++ "test/a/really/really/really\n" + ++ "test/a/really/really/really/really\n" + ++ "test/a/really/really/really/really/really\n" + ++ "test/a/really/really/really/really/really/really\n" + ++ "test/a/really/really/really/really/really/really/really\n" + ++ "test/a/really/really/really/really/really/really/really/really\n" + ++ "test/a/really/really/really/really/really/really/really/really/long/path\n"; + try writer.writeAll(expected_message); + try writer.flush(); + + const data = try Cache.read(allocator, reader); + try std.testing.expectEqualDeep(Data { + .untracked_file_paths = .{ .data = &[_][]const u8{ + "test\n", + "test/a/really\n", + "test/a/really/really\n", + "test/a/really/really/really\n", + "test/a/really/really/really/really\n", + "test/a/really/really/really/really/really\n", + "test/a/really/really/really/really/really/really\n", + "test/a/really/really/really/really/really/really/really\n", + "test/a/really/really/really/really/really/really/really/really\n", + "test/a/really/really/really/really/really/really/really/really/long/path\n", + } }, + }, data); +} diff --git a/src/Git.zig b/src/Git.zig new file mode 100644 index 0000000..f38cf94 --- /dev/null +++ b/src/Git.zig @@ -0,0 +1,285 @@ +const Git = @This(); + +const Cache = @import("Cache.zig"); +pub const c = @cImport({ + @cInclude("git2.h"); +}); +const config = @import("config"); +const perf = @import("perf.zig"); +const std = @import("std"); +const util = @import("util.zig"); + +repo: ?*c.git_repository, +git_path: []const u8, +repo_path: []const u8, +allocator: std.mem.Allocator, +repo_state: c_int, +cache: Cache, + +pub fn init(allocator: std.mem.Allocator) !?*Git { + const s = perf.s(); + defer perf.e(s, @src()); + + // ensure we're in a git repo + const repo_root = findRepoRoot(allocator) catch |err| switch (err) { + error.OutOfMemory => util.oom(), + else => if (config.err) return err else null, + }; + if (repo_root == null) return null; + + // disable some libgit2 features + _ = c.git_libgit2_opts(c.GIT_OPT_ENABLE_CACHING, @as(u32, 0)); + _ = c.git_libgit2_opts(c.GIT_OPT_ENABLE_STRICT_HASH_VERIFICATION, @as(u32, 0)); + + if (c.git_libgit2_init() < 0) { + util.logErr("Failed to initalize libgit2, proceeding without git functionality enabled.", .{}); + return null; + } + + const self = try allocator.create(Git); + self.* = .{ + .repo = undefined, + .git_path = repo_root.?, + .allocator = allocator, + .repo_state = undefined, + .repo_path = undefined, + .cache = .init(self.allocator, self.git_path), + }; + + if (c.git_repository_open(&self.repo, repo_root.?.ptr) != 0) { + util.logErr("Failed to open git repo: {s}", .{ + c.git_error_last().*.message, + }); + return null; + } + + self.repo_state = c.git_repository_state(self.repo); + self.repo_path = try std.fs.path.resolve(allocator, &[_][]const u8{ + self.git_path, + "..", + }); + + return self; +} + +pub fn deinit(self: *Git) void { + const s = perf.s(); + defer perf.e(s, @src()); + + self.allocator.free(self.git_path); + self.allocator.free(self.repo_path); + c.git_repository_free(self.repo); + self.allocator.destroy(self); + _ = c.git_libgit2_shutdown(); +} + +pub fn hasStashes(self: *Git) bool { + const s = perf.s(); + defer perf.e(s, @src()); + + var stash: ?*c.struct_git_reference = null; + const res = c.git_reference_lookup(&stash, self.repo, "refs/stash"); + defer c.git_reference_free(stash); + + if (res == c.GIT_ENOTFOUND) { + return false; + } else if (res < 0) { + util.logErr("Error looking up stash reference: {s}", .{ + c.git_error_last().*.message, + }); + return false; + } else return true; +} + +pub fn inNewRepo(self: *Git) bool { + return c.git_repository_is_empty(self.repo) == 1; +} + +pub fn inMerge(self: *Git) bool { + const s = perf.s(); + defer perf.e(s, @src()); + + return self.repo_state == c.GIT_REPOSITORY_STATE_MERGE; +} + +pub fn inRebase(self: *Git) bool { + const s = perf.s(); + defer perf.e(s, @src()); + + return switch (self.repo_state) { + c.GIT_REPOSITORY_STATE_REBASE, + c.GIT_REPOSITORY_STATE_REBASE_INTERACTIVE, + c.GIT_REPOSITORY_STATE_REBASE_MERGE => true, + else => false, + }; +} + +pub fn hasStaged(self: *Git) bool { + const s = perf.s(); + defer perf.e(s, @src()); + + var entry: c.git_status_entry = undefined; + var list: ?*c.git_status_list = undefined; + var opts: c.git_status_options = .{ + .show = c.GIT_STATUS_SHOW_INDEX_ONLY, + .flags = c.GIT_STATUS_INDEX_NEW + | c.GIT_STATUS_OPT_NO_REFRESH, + .version = c.GIT_STATUS_OPTIONS_VERSION, + }; + + if (c.git_status_list_new(&list, self.repo, &opts) < 0) { + util.logErr("Error checking for staged changes: {s}", .{ + c.git_error_last().*.message, + }); + return false; + } + defer c.git_status_list_free(list); + + const count = c.git_status_list_entrycount(list); + for (0..count) |i| { + const entry_ptr = c.git_status_byindex(list, i); + entry = entry_ptr.*; + if ((entry.status & c.GIT_STATUS_INDEX_NEW + | c.GIT_STATUS_INDEX_DELETED + | c.GIT_STATUS_INDEX_MODIFIED) != 0) { + return true; + } + } + + return false; +} + +pub fn hasUntracked(self: *Git) bool { + const s = perf.s(); + defer perf.e(s, @src()); + + var list: ?*c.git_status_list = undefined; + var opts: c.git_status_options = .{ + .show = c.GIT_STATUS_SHOW_INDEX_AND_WORKDIR, + .flags = c.GIT_STATUS_OPT_INCLUDE_UNTRACKED + | c.GIT_STATUS_OPT_NO_REFRESH, + .version = c.GIT_STATUS_OPTIONS_VERSION, + }; + + read_cache: { + const data = self.cache.readFromFile() catch |err| { + util.logErr("Failed to read cache from disk: {}", .{err}); + switch (err) { + error.OutOfMemory => util.oom(), + else => break :read_cache, // leave caching code early + } + }; + for (data.untracked_file_paths.data) |path| { + const file_path: [:0]u8 = @ptrCast(@constCast(path)); + file_path[file_path.len - 1] = 0; + + // TODO: combine the paths into one pathspec and diff all files at once + var pathspec_paths = self.allocator.alloc([*c]u8, 2) catch return false; + pathspec_paths[0] = file_path; + pathspec_paths[1] = null; + var cache_opts: c.git_diff_options = .{ + .flags = c.GIT_DIFF_INCLUDE_UNTRACKED + | c.GIT_DIFF_RECURSE_UNTRACKED_DIRS, + .version = c.GIT_DIFF_OPTIONS_VERSION, + .pathspec = .{ + .count = 1, + .strings = pathspec_paths.ptr, + }, + }; + var diff: ?*c.git_diff = undefined; + if (c.git_diff_index_to_workdir(&diff, self.repo, null, &cache_opts) < 0) { + util.logErr("Error diffing file '{s}': {s}", .{ + path, + c.git_error_last().*.message, + }); + continue; + } + defer c.git_diff_free(diff); + if (c.git_diff_num_deltas(diff) > 0) return true; + } + } + + if (c.git_status_list_new(&list, self.repo, &opts) < 0) { + util.logErr("Error checking for staged changes: {s}", .{ + c.git_error_last().*.message, + }); + return false; + } + defer c.git_status_list_free(list); + + const max_entries = 10; + const untracked_entries = @min(c.git_status_list_entrycount(list), max_entries); + if (untracked_entries > 0) write_cache: { + var paths: [][]const u8 = self.allocator.alloc( + []const u8, + untracked_entries, + ) catch return false; + for (0..untracked_entries) |i| { + const entry = c.git_status_byindex(list, i); + if (entry) |e| if (e.*.index_to_workdir) |itw| { + paths[i] = std.mem.span(itw.*.new_file.path); + }; + } + self.cache.writeToFile(.{ + .untracked_file_paths = .{ .data = paths }, + }) catch |err| { + util.logErr("Failed to write cache to disk: {}", .{err}); + break :write_cache; + }; + } + + return untracked_entries > 0; +} + +pub fn headIsDetached(self: *Git) bool { + const s = perf.s(); + defer perf.e(s, @src()); + + return c.git_repository_head_detached(self.repo) == 1; +} + +fn findRepoRoot(allocator: std.mem.Allocator) !?[]u8 { + const s = perf.s(); + defer perf.e(s, @src()); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = std.fs.realpath(".", &buf) catch return null; + + // count the number of jumps back to the root of the fs + var root_steps: u32 = 0; + for (path) |ch| { + if (ch == std.fs.path.sep) root_steps += 1; + } + + var cur_path = std.fs.cwd(); + for (path) |_| { + const stat = cur_path.statFile(".git") catch { + // go up a directory if the current one doesn't work + cur_path = try cur_path.openDir("..", .{}); + continue; + }; + + return switch (stat.kind) { + .directory => try allocator.dupe(u8, try cur_path.realpathZ( + ".git", + &buf, + )), + .file => { + @branchHint(.cold); + const f = try cur_path.openFile(".git", .{ .mode = .read_only }); + const file_reader = f.reader(&buf); + var reader = file_reader.interface; + const read = try reader.readSliceShort(&buf); + const prefix = "gitdir: "; + if (std.mem.startsWith(u8, &buf, prefix)) { + buf[read - 1] = 0; // remove the newline + return try allocator.dupe(u8, buf[prefix.len..]); + } + return null; + }, + else => null, + }; + } + + return null; +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..6d92890 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,185 @@ +const std = @import("std"); +const config = @import("config"); +const perf = @import("perf.zig"); +const util = @import("util.zig"); + +const FaceParts = enum(u32) { eyes, nose, mouth }; +const FacePart = struct { symbol: u8, explanation: []const u8 }; + +pub fn main() u8 { + const s = perf.s(); + defer perf.e(s, @src()); + + // state + var explain = false; + var face: [@typeInfo(FaceParts).@"enum".fields.len]?FacePart = .{ + null, + null, + null, + }; + + errdefer |err| { + util.logErr("{}", .{ err }); + std.process.exit(1); + } + + var stdout_buffer: [1024]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + const stdout = &stdout_writer.interface; + + var signal: ?u8 = null; + const argv = std.os.argv; + if (argv.len > 1) { + if (std.mem.eql(u8, std.mem.span(argv[1]), "-v")) { + try stdout.writeAll("XD v" ++ config.version ++ "\n" + // I tried to make this into a function but the zig compiler wouldn't + // accept that the return type was comptime known, this sucks + ++ "git " ++ b: { if (config.git) break :b "enabled" else break :b "disabled"; } ++ "\n" + ++ "explinations " ++ b: { if (config.explain) break :b "enabled" else break :b "disabled"; } ++ "\n" + ++ "v5 cleanup " ++ b: { if (config.cleanup) break :b "enabled" else break :b "disabled"; } ++ "\n" + ++ "dynamic linking " ++ b: { if (config.dynamic) break :b "enabled" else break :b "disabled"; } ++ "\n" + ++ "performance monitoring " ++ b: { if (config.perf) break :b "enabled" else break :b "disabled"; } ++ "\n" + ++ "error logs " ++ b: { if (config.err) break :b "enabled" else break :b "disabled"; } ++ "\n" + ); + try stdout.flush(); + std.process.exit(0); + } else if (config.explain and std.mem.eql(u8, std.mem.span(argv[1]), "-e")) { + explain = true; + } + signal = std.fmt.parseInt(u8, std.mem.span(argv[argv.len - 1]), 10) catch { + util.logErr("The last arument must be a number!", .{}); + return 1; + }; + } + + var arena_allocator: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena_allocator.deinit(); + const allocator = arena_allocator.allocator(); + + var git = if (config.git) try @import("Git.zig").init(allocator) orelse null; + defer if (config.git and git != null) git.?.deinit(); + if (config.git and git != null) { + if (git.?.hasStashes()) { + face[@intFromEnum(FaceParts.eyes)] = .{ + .symbol = '8', + .explanation = "The current git repo has stashed changes.", + }; + } else if (git.?.inMerge()) { + @branchHint(.unlikely); + face[@intFromEnum(FaceParts.eyes)] = .{ + .symbol = 'X', + .explanation = "The current git repo is in the middle of a merge.", + }; + } else if (git.?.inRebase()) { + @branchHint(.cold); + face[@intFromEnum(FaceParts.eyes)] = .{ + .symbol = 'O', + .explanation = "The current git repo is in the middle of a rebase.", + }; + } else if (git.?.inNewRepo()) { + @branchHint(.cold); + face[@intFromEnum(FaceParts.eyes)] = .{ + .symbol = 'B', + .explanation = "The current git repo is has no commits.", + }; + } else { + face[@intFromEnum(FaceParts.eyes)] = .{ + .symbol = ';', + .explanation = "In a git repository.", + }; + } + + if (git.?.hasStaged()) { + face[@intFromEnum(FaceParts.nose)] = .{ + .symbol = '*', + .explanation = "There are staged changes.", + }; + } else if (git.?.hasUntracked()) { + @branchHint(.likely); + face[@intFromEnum(FaceParts.nose)] = .{ + .symbol = '^', + .explanation = "There are untracked changes in the repository.", + }; + } else if (git.?.headIsDetached()) { + face[@intFromEnum(FaceParts.nose)] = .{ + .symbol = '-', + .explanation = "The HEAD is detached.", + }; + } + } else { + @branchHint(.likely); + face[@intFromEnum(FaceParts.eyes)] = .{ + .symbol = ':', + .explanation = "Not in a git repository.", + }; + } + + if (signal) |sig| { + face[@intFromEnum(FaceParts.mouth)] = blk: switch (sig) { + 0 => { + @branchHint(.likely); + break :blk .{ + .symbol = ')', + .explanation = "Return code of 0, no errors.", + }; + }, + 130 => { + @branchHint(.likely); + break :blk .{ + .symbol = 'O', + .explanation = "Return code of 130, ctrl-c was pressed, or SIGTERM sent.", + }; + }, + 126 => { + @branchHint(.cold); + break :blk .{ + .symbol = 'P', + .explanation = "Return code of 126, permission denied.", + }; + }, + 127 => { + @branchHint(.unlikely); + break :blk .{ + .symbol = '/', + .explanation = "Return code of 127, command not found.", + }; + }, + else => { + @branchHint(.likely); + var buf: [60]u8 = undefined; + break :blk .{ + .symbol = '(', + .explanation = try std.fmt.bufPrint( + &buf, + "Return code of {}, probably an error with the program.", + .{ sig } + ), + }; + }, + }; + } else { + @branchHint(.cold); + face[@intFromEnum(FaceParts.mouth)] = .{ + .symbol = '|', + .explanation = "No code information passed in.", + }; + } + + // print out face parts + for (face) |part| { + if (part == null) continue; + if (explain) { + @branchHint(.cold); + try stdout.print("{s}\n", .{ part.?.explanation }); + } else { + _ = try stdout.write(@ptrCast(&part.?.symbol)); + } + } + + try stdout.flush(); + return signal orelse 0; +} + +test "main" { + _ = @import("Cache.zig"); +} diff --git a/src/perf.zig b/src/perf.zig new file mode 100644 index 0000000..b014faf --- /dev/null +++ b/src/perf.zig @@ -0,0 +1,19 @@ +const Perf = @This(); + +const std = @import("std"); +const config = @import("config"); + +pub fn s() ?i128 { + if (!config.perf) return null; + return std.time.nanoTimestamp(); +} + +pub fn e(start: ?i128, src: std.builtin.SourceLocation) void { + if (!config.perf or start == null) return; + const elapsed = std.time.nanoTimestamp() - start.?; + std.debug.print("{s}.{s}(): {}ms\n", .{ + src.file[0..src.file.len - 4], // - 4 removes ".zig" from the file name + src.fn_name, + @divExact(@as(f128, @floatFromInt(elapsed)), std.time.ns_per_ms), + }); +} diff --git a/src/util.zig b/src/util.zig new file mode 100644 index 0000000..39c4e56 --- /dev/null +++ b/src/util.zig @@ -0,0 +1,14 @@ +const util = @This(); + +const std = @import("std"); +const config = @import("config"); + +pub fn logErr(comptime format: []const u8, args: anytype) void { + if (!config.err) return; + std.debug.print("err: " ++ format ++ "\n", args); +} + +pub fn oom() noreturn { + logErr("Ran out of memory, whoops.", .{}); + std.process.exit(1); +}