#include "git/git_context.h" #include "foundation/compat_fs.h" #include "foundation/constants.h" #include "foundation/log.h" #include "foundation/platform.h" #include "foundation/str_util.h" #include #include #include #include #include enum { GIT_CMD_MAX = 1024, GIT_OUTPUT_MAX = 4096, }; static char *git_strdup(const char *s) { if (!s) { s = ""; } size_t n = strlen(s) + 1; char *out = (char *)malloc(n); if (!out) { return NULL; } memcpy(out, s, n); return out; } static void trim_newlines(char *s) { if (!s) { return; } size_t n = strlen(s); while (n > 0 && (s[n - 1] == '\n' || s[n - 1] == '\r')) { s[--n] = '\0'; } } static bool git_validate_repo_path(const char *repo_path) { return cbm_validate_shell_path_arg(repo_path); } /* Run `git -C repo_path git_args` and copy the first output line (newlines * trimmed) into buf. Returns 0 only when git exited 0 with a non-empty line. */ static int git_capture_into(const char *repo_path, const char *git_args, char *buf, size_t buf_size) { if (!buf || buf_size == 0) { return CBM_NOT_FOUND; } buf[0] = '\0'; if (!repo_path || !git_args || !git_validate_repo_path(repo_path)) { return CBM_NOT_FOUND; } char cmd[GIT_CMD_MAX]; #ifdef _WIN32 const char *null_dev = "NUL"; #else const char *null_dev = "/dev/null"; #endif /* Double quotes work for POSIX shells and cmd.exe. cbm_validate_shell_arg() * rejects quote/backslash/substitution metacharacters before interpolation. */ int n = snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s 2>%s", repo_path, git_args, null_dev); if (n < 0 || n >= (int)sizeof(cmd)) { return CBM_NOT_FOUND; } /* Debug-level stage markers: none of these waits has a deadline, so a * stuck git child is named by the last marker printed (spawn, read or * close). Silent at the default level. */ cbm_log_debug("git.capture", "stage", "spawn", "args", git_args); FILE *fp = cbm_popen_git(cmd); if (!fp) { return CBM_NOT_FOUND; } cbm_log_debug("git.capture", "stage", "read", "args", git_args); if (!fgets(buf, (int)buf_size, fp)) { buf[0] = '\0'; cbm_log_debug("git.capture", "stage", "close", "args", git_args, "output", "none"); cbm_pclose(fp); cbm_log_debug("git.capture", "stage", "done", "args", git_args); return CBM_NOT_FOUND; } trim_newlines(buf); cbm_log_debug("git.capture", "stage", "close", "args", git_args); int rc = cbm_pclose(fp); cbm_log_debug("git.capture", "stage", "done", "args", git_args); if (rc != 0 || buf[0] == '\0') { buf[0] = '\0'; return CBM_NOT_FOUND; } return 0; } static int git_capture(const char *repo_path, const char *git_args, char **out) { if (!out) { return CBM_NOT_FOUND; } *out = NULL; char buf[GIT_OUTPUT_MAX]; if (git_capture_into(repo_path, git_args, buf, sizeof(buf)) != 0) { return CBM_NOT_FOUND; } *out = git_strdup(buf); return *out ? 0 : CBM_NOT_FOUND; } static bool path_is_absolute(const char *path) { if (!path || !path[0]) { return false; } if (path[0] == '/') { return true; } #ifdef _WIN32 return isalpha((unsigned char)path[0]) && path[1] == ':'; #else return false; #endif } static char *join_root_relative(const char *root, const char *rel) { if (!root || !root[0]) { return git_strdup(rel); } int n = snprintf(NULL, 0, "%s/%s", root, rel); if (n < 0) { return NULL; } char *out = (char *)malloc((size_t)n + 1); if (!out) { return NULL; } snprintf(out, (size_t)n + 1, "%s/%s", root, rel); return out; } /* Derive the canonical repo root. * * Preferred (git 2.31+): abs_common_dir is `git rev-parse --path-format=absolute * --git-common-dir` — git's OWN absolute, canonical common-dir. Because a main * repo and its linked worktree both ask the same git binary, they resolve to the * IDENTICAL path, so canonical_root is consistent across worktrees AND platforms * with no manual join or realpath/_fullpath — which diverged under msys vs native * path representations (#659 root cause, and the worktree==main-root breakage in * test_pipeline.c git_context_linked_worktree). git also resolves the relative * ".." internally, so the subdirectory case (#659) is fixed here too. * * Fallback (git < 2.31, no --path-format → abs_common_dir empty): the relative * --git-common-dir is relative to input_path (the -C dir), so join against it and * realpath-normalize the "..". Unix only — on Windows git emits an absolute * common-dir so this branch isn't reached in practice, and _fullpath there * reintroduces the msys divergence. */ static char *derive_canonical_root(const char *input_path, const char *worktree_root, const char *git_common_dir, const char *abs_common_dir) { char *root = NULL; if (abs_common_dir && abs_common_dir[0] && path_is_absolute(abs_common_dir)) { root = git_strdup(abs_common_dir); if (!root) { return NULL; } } else { const char *src = git_common_dir && git_common_dir[0] ? git_common_dir : worktree_root; if (!src) { return git_strdup(""); } #ifndef _WIN32 root = path_is_absolute(src) ? git_strdup(src) : join_root_relative(input_path, src); if (!root) { return NULL; } { char resolved[4096]; if (realpath(root, resolved) != NULL) { free(root); root = git_strdup(resolved); if (!root) { return NULL; } } } #else (void)input_path; root = path_is_absolute(src) ? git_strdup(src) : join_root_relative(worktree_root, src); if (!root) { return NULL; } #endif } size_t len = strlen(root); while (len > 1 && (root[len - 1] == '/' || root[len - 1] == '\\')) { root[--len] = '\0'; } if (len >= 5 && strcmp(root + len - 5, "/.git") == 0) { root[len - 5] = '\0'; } #ifdef _WIN32 else if (len >= 5 && strcmp(root + len - 5, "\\.git") == 0) { root[len - 5] = '\0'; } #endif return root; } static char *slug_from_branch(const char *branch, bool detached) { const char *fallback = detached ? "detached" : "working-tree"; const char *src = detached ? fallback : (branch && branch[0] ? branch : fallback); size_t len = strlen(src); char *slug = (char *)malloc(len + 1); if (!slug) { return NULL; } size_t j = 0; bool in_dash = false; for (size_t i = 0; i < len; i++) { unsigned char c = (unsigned char)src[i]; if (isalnum(c) || c == '-' || c == '_' || c == '.') { if (j == 0 && c == '-') { in_dash = true; continue; } slug[j++] = (char)c; in_dash = false; } else if (j > 0 && !in_dash) { slug[j++] = '-'; in_dash = true; } } while (j > 0 && slug[j - 1] == '-') { j--; } slug[j] = '\0'; if (slug[0] == '\0') { free(slug); return git_strdup(fallback); } return slug; } void cbm_git_context_free(cbm_git_context_t *ctx) { if (!ctx) { return; } free(ctx->input_path); free(ctx->worktree_root); free(ctx->git_dir); free(ctx->git_common_dir); free(ctx->canonical_root); free(ctx->branch); free(ctx->branch_slug); free(ctx->head_sha); free(ctx->base_sha); memset(ctx, 0, sizeof(*ctx)); } int cbm_git_context_resolve(const char *path, cbm_git_context_t *out) { if (!out) { return CBM_NOT_FOUND; } memset(out, 0, sizeof(*out)); if (!path || !path[0]) { return CBM_NOT_FOUND; } out->input_path = git_strdup(path); if (!out->input_path) { return CBM_NOT_FOUND; } out->root_exists = cbm_is_dir(path); if (!out->root_exists) { return 0; } if (git_capture(path, "rev-parse --show-toplevel", &out->worktree_root) != 0) { out->is_git = false; return 0; } out->is_git = true; if (git_capture(path, "rev-parse --git-dir", &out->git_dir) != 0) { out->git_dir = git_strdup(""); } if (git_capture(path, "rev-parse --git-common-dir", &out->git_common_dir) != 0) { out->git_common_dir = git_strdup(""); } if (git_capture(path, "rev-parse --verify HEAD", &out->head_sha) != 0) { out->head_sha = git_strdup(""); } if (git_capture(path, "symbolic-ref --quiet --short HEAD", &out->branch) != 0) { out->branch = git_strdup("DETACHED"); out->is_detached = true; } out->is_worktree = out->git_dir && out->git_common_dir && strcmp(out->git_dir, out->git_common_dir) != 0; /* git 2.31+ canonical absolute common-dir (best-effort; NULL on older git, * where derive_canonical_root falls back to the relative common-dir). */ char *abs_common_dir = NULL; (void)git_capture(path, "rev-parse --path-format=absolute --git-common-dir", &abs_common_dir); out->canonical_root = derive_canonical_root(path, out->worktree_root, out->git_common_dir, abs_common_dir); free(abs_common_dir); out->branch_slug = slug_from_branch(out->branch, out->is_detached); if (git_capture(path, "merge-base HEAD @{upstream}", &out->base_sha) != 0) { out->base_sha = git_strdup(""); } if (!out->git_dir || !out->git_common_dir || !out->head_sha || !out->branch || !out->canonical_root || !out->branch_slug || !out->base_sha) { cbm_git_context_free(out); return CBM_NOT_FOUND; } return 0; } int cbm_git_branch_resolve(const char *path, bool *is_git, char *branch, size_t branch_size) { if (!is_git || !branch || branch_size == 0) { return CBM_NOT_FOUND; } *is_git = false; branch[0] = '\0'; if (!path || !path[0]) { return CBM_NOT_FOUND; } if (!cbm_is_dir(path)) { return 0; } char worktree_root[GIT_OUTPUT_MAX]; if (git_capture_into(path, "rev-parse --show-toplevel", worktree_root, sizeof(worktree_root)) != 0) { return 0; } *is_git = true; if (git_capture_into(path, "symbolic-ref --quiet --short HEAD", branch, branch_size) != 0) { snprintf(branch, branch_size, "%s", "DETACHED"); } return 0; } char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx) { const char *project = project_name && project_name[0] ? project_name : "project"; const char *slug = "working-tree"; if (ctx) { if (ctx->is_detached) { slug = "detached"; } else if (ctx->is_git && ctx->branch_slug && ctx->branch_slug[0]) { slug = ctx->branch_slug; } } int n = snprintf(NULL, 0, "%s.__branch__.%s", project, slug); if (n < 0) { return NULL; } char *out = (char *)malloc((size_t)n + 1); if (!out) { return NULL; } snprintf(out, (size_t)n + 1, "%s.__branch__.%s", project, slug); return out; } static bool append_fmt_checked(char *buf, int buf_size, int *off, const char *fmt, ...) { if (!buf || !off || buf_size <= 0 || *off < 0 || *off >= buf_size) { return false; } va_list ap; va_start(ap, fmt); int n = vsnprintf(buf + *off, (size_t)(buf_size - *off), fmt, ap); va_end(ap); if (n < 0 || n >= buf_size - *off) { buf[buf_size - 1] = '\0'; return false; } *off += n; return true; } static int json_escaped_len(const char *src) { if (!src) { return 0; } int len = 0; for (int i = 0; src[i]; i++) { unsigned char c = (unsigned char)src[i]; if (c == '"' || c == '\\' || c == '\n' || c == '\r' || c == '\t') { len += 2; } else if (c < 0x20) { len += 6; /* \u00XX */ } else { len++; } } return len; } static bool json_append_bool(char *buf, int buf_size, int *off, const char *name, bool value, bool comma) { return append_fmt_checked(buf, buf_size, off, "\"%s\":%s%s", name, value ? "true" : "false", comma ? "," : ""); } static bool json_append_string(char *buf, int buf_size, int *off, const char *name, const char *value, bool comma) { int needed = json_escaped_len(value ? value : ""); char *escaped = malloc((size_t)needed + 1); if (!escaped) { return false; } int actual = cbm_json_escape(escaped, needed + 1, value ? value : ""); bool ok = actual == needed && append_fmt_checked(buf, buf_size, off, "\"%s\":\"%s\"%s", name, escaped, comma ? "," : ""); free(escaped); return ok; } int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_size) { if (!ctx || !buf || buf_size <= 0) { return 0; } int off = 0; bool ok = append_fmt_checked(buf, buf_size, &off, "{") && json_append_bool(buf, buf_size, &off, "is_git", ctx->is_git, true) && json_append_bool(buf, buf_size, &off, "is_worktree", ctx->is_worktree, true) && json_append_bool(buf, buf_size, &off, "is_detached", ctx->is_detached, true) && json_append_bool(buf, buf_size, &off, "root_exists", ctx->root_exists, true) && json_append_string(buf, buf_size, &off, "canonical_root", ctx->canonical_root, true) && json_append_string(buf, buf_size, &off, "worktree_root", ctx->worktree_root, true) && json_append_string(buf, buf_size, &off, "git_common_dir", ctx->git_common_dir, true) && json_append_string(buf, buf_size, &off, "branch", ctx->branch, true) && json_append_string(buf, buf_size, &off, "head_sha", ctx->head_sha, true) && json_append_string(buf, buf_size, &off, "base_sha", ctx->base_sha, false) && append_fmt_checked(buf, buf_size, &off, "}"); if (!ok) { if (buf_size > 0) { buf[0] = '\0'; } return 0; } return off; }