diff --git a/.gitignore b/.gitignore index 22860799..4dbf8d36 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ /ds4_native /ds4_server_test /ds4_test +/tests/ds4_agent_context_test +/tests/ds4_agent_git_test +/tests/generated/ /ds4flash.gguf /TODO.md /gguf/ diff --git a/Makefile b/Makefile index 27283ba0..cd1c5be7 100644 --- a/Makefile +++ b/Makefile @@ -57,15 +57,15 @@ ds4-bench: ds4_bench.o $(CORE_OBJS) ds4-eval: ds4_eval.o $(CORE_OBJS) $(CC) $(CFLAGS) -o $@ ds4_eval.o $(CORE_OBJS) $(METAL_LDLIBS) -ds4-agent: ds4_agent.o ds4_web.o ds4_kvstore.o linenoise.o $(CORE_OBJS) - $(CC) $(CFLAGS) -o $@ ds4_agent.o ds4_web.o ds4_kvstore.o linenoise.o $(CORE_OBJS) $(METAL_LDLIBS) +ds4-agent: ds4_agent.o ds4_agent_git.o ds4_web.o ds4_kvstore.o linenoise.o $(CORE_OBJS) + $(CC) $(CFLAGS) -o $@ ds4_agent.o ds4_agent_git.o ds4_web.o ds4_kvstore.o linenoise.o $(CORE_OBJS) $(METAL_LDLIBS) -cpu: ds4_cli_cpu.o ds4_server_cpu.o ds4_bench_cpu.o ds4_eval_cpu.o ds4_agent_cpu.o ds4_web.o ds4_kvstore.o linenoise.o rax.o $(CPU_CORE_OBJS) +cpu: ds4_cli_cpu.o ds4_server_cpu.o ds4_bench_cpu.o ds4_eval_cpu.o ds4_agent_cpu.o ds4_agent_git.o ds4_web.o ds4_kvstore.o linenoise.o rax.o $(CPU_CORE_OBJS) $(CC) $(CFLAGS) -o ds4 ds4_cli_cpu.o linenoise.o $(CPU_CORE_OBJS) $(LDLIBS) $(CC) $(CFLAGS) -o ds4-server ds4_server_cpu.o ds4_kvstore.o rax.o $(CPU_CORE_OBJS) $(LDLIBS) $(CC) $(CFLAGS) -o ds4-bench ds4_bench_cpu.o $(CPU_CORE_OBJS) $(LDLIBS) $(CC) $(CFLAGS) -o ds4-eval ds4_eval_cpu.o $(CPU_CORE_OBJS) $(LDLIBS) - $(CC) $(CFLAGS) -o ds4-agent ds4_agent_cpu.o ds4_web.o ds4_kvstore.o linenoise.o $(CPU_CORE_OBJS) $(LDLIBS) + $(CC) $(CFLAGS) -o ds4-agent ds4_agent_cpu.o ds4_agent_git.o ds4_web.o ds4_kvstore.o linenoise.o $(CPU_CORE_OBJS) $(LDLIBS) cuda-regression: @echo "cuda-regression requires a CUDA build" @@ -107,15 +107,15 @@ ds4-bench: ds4_bench.o $(CORE_OBJS) ds4-eval: ds4_eval.o $(CORE_OBJS) $(NVCC) $(NVCCFLAGS) -o $@ $^ $(CUDA_LDLIBS) -ds4-agent: ds4_agent.o ds4_web.o ds4_kvstore.o linenoise.o $(CORE_OBJS) +ds4-agent: ds4_agent.o ds4_agent_git.o ds4_web.o ds4_kvstore.o linenoise.o $(CORE_OBJS) $(NVCC) $(NVCCFLAGS) -o $@ $^ $(CUDA_LDLIBS) -cpu: ds4_cli_cpu.o ds4_server_cpu.o ds4_bench_cpu.o ds4_eval_cpu.o ds4_agent_cpu.o ds4_web.o ds4_kvstore.o linenoise.o rax.o $(CPU_CORE_OBJS) +cpu: ds4_cli_cpu.o ds4_server_cpu.o ds4_bench_cpu.o ds4_eval_cpu.o ds4_agent_cpu.o ds4_agent_git.o ds4_web.o ds4_kvstore.o linenoise.o rax.o $(CPU_CORE_OBJS) $(CC) $(CFLAGS) -o ds4 ds4_cli_cpu.o linenoise.o $(CPU_CORE_OBJS) $(LDLIBS) $(CC) $(CFLAGS) -o ds4-server ds4_server_cpu.o ds4_kvstore.o rax.o $(CPU_CORE_OBJS) $(LDLIBS) $(CC) $(CFLAGS) -o ds4-bench ds4_bench_cpu.o $(CPU_CORE_OBJS) $(LDLIBS) $(CC) $(CFLAGS) -o ds4-eval ds4_eval_cpu.o $(CPU_CORE_OBJS) $(LDLIBS) - $(CC) $(CFLAGS) -o ds4-agent ds4_agent_cpu.o ds4_web.o ds4_kvstore.o linenoise.o $(CPU_CORE_OBJS) $(LDLIBS) + $(CC) $(CFLAGS) -o ds4-agent ds4_agent_cpu.o ds4_agent_git.o ds4_web.o ds4_kvstore.o linenoise.o $(CPU_CORE_OBJS) $(LDLIBS) cuda-regression: tests/cuda_long_context_smoke ./tests/cuda_long_context_smoke @@ -136,9 +136,12 @@ ds4_bench.o: ds4_bench.c ds4.h ds4_eval.o: ds4_eval.c ds4.h $(CC) $(CFLAGS) -c -o $@ ds4_eval.c -ds4_agent.o: ds4_agent.c ds4.h ds4_kvstore.h ds4_web.h linenoise.h +ds4_agent.o: ds4_agent.c ds4.h ds4_agent_git.h ds4_kvstore.h ds4_web.h linenoise.h $(CC) $(CFLAGS) -c -o $@ ds4_agent.c +ds4_agent_git.o: ds4_agent_git.c ds4_agent_git.h + $(CC) $(CFLAGS) -c -o $@ ds4_agent_git.c + ds4_web.o: ds4_web.c ds4_web.h $(CC) $(CFLAGS) -c -o $@ ds4_web.c @@ -151,6 +154,9 @@ ds4_test.o: tests/ds4_test.c ds4_server.c ds4.h ds4_kvstore.h rax.h tests/cuda_long_context_smoke.o: tests/cuda_long_context_smoke.c ds4_gpu.h $(CC) $(CFLAGS) -I. -c -o $@ tests/cuda_long_context_smoke.c +tests/ds4_agent_git_test.o: tests/ds4_agent_git_test.c ds4_agent_git.h + $(CC) $(CFLAGS) -I. -c -o $@ tests/ds4_agent_git_test.c + rax.o: rax.c rax.h rax_malloc.h $(CC) $(CFLAGS) -c -o $@ rax.c @@ -172,7 +178,7 @@ ds4_bench_cpu.o: ds4_bench.c ds4.h ds4_eval_cpu.o: ds4_eval.c ds4.h $(CC) $(CFLAGS) -DDS4_NO_GPU -c -o $@ ds4_eval.c -ds4_agent_cpu.o: ds4_agent.c ds4.h ds4_kvstore.h ds4_web.h linenoise.h +ds4_agent_cpu.o: ds4_agent.c ds4.h ds4_agent_git.h ds4_kvstore.h ds4_web.h linenoise.h $(CC) $(CFLAGS) -DDS4_NO_GPU -c -o $@ ds4_agent.c ds4_metal.o: ds4_metal.m ds4_gpu.h $(METAL_SRCS) @@ -184,6 +190,9 @@ ds4_cuda.o: ds4_cuda.cu ds4_gpu.h ds4_iq2_tables_cuda.inc tests/cuda_long_context_smoke: tests/cuda_long_context_smoke.o ds4_cuda.o $(NVCC) $(NVCCFLAGS) -o $@ $^ $(CUDA_LDLIBS) +tests/ds4_agent_git_test: tests/ds4_agent_git_test.o ds4_agent_git.o + $(CC) $(CFLAGS) -o $@ tests/ds4_agent_git_test.o ds4_agent_git.o $(LDLIBS) + ds4_test: ds4_test.o ds4_kvstore.o rax.o $(CORE_OBJS) ifeq ($(UNAME_S),Darwin) $(CC) $(CFLAGS) -o $@ ds4_test.o ds4_kvstore.o rax.o $(CORE_OBJS) $(METAL_LDLIBS) @@ -191,9 +200,10 @@ else $(NVCC) $(NVCCFLAGS) -o $@ ds4_test.o ds4_kvstore.o rax.o $(CORE_OBJS) $(CUDA_LDLIBS) endif -test: ds4_test ds4-eval +test: ds4_test ds4-eval tests/ds4_agent_git_test ./ds4-eval --self-test-extractors + ./tests/ds4_agent_git_test ./ds4_test clean: - rm -f ds4 ds4-server ds4-bench ds4-eval ds4-agent ds4_cpu ds4_native ds4_server_test ds4_test *.o tests/cuda_long_context_smoke tests/cuda_long_context_smoke.o + rm -f ds4 ds4-server ds4-bench ds4-eval ds4-agent ds4_cpu ds4_native ds4_server_test ds4_test *.o tests/cuda_long_context_smoke tests/cuda_long_context_smoke.o tests/ds4_agent_git_test tests/ds4_agent_git_test.o diff --git a/docs/agent-git-tools.md b/docs/agent-git-tools.md new file mode 100644 index 00000000..079720ca --- /dev/null +++ b/docs/agent-git-tools.md @@ -0,0 +1,93 @@ +# Agent Git Tools + +This branch keeps Git support independent from agent context/KV checkpointing. +The native `git` DSML tool is wired through `ds4_agent_git.c` and does not link +against `ds4_agent_context.c`. + +## Goals + +- Provide fast local repository inspection without shell parsing. +- Keep all argv construction direct through `fork`/`exec`, never through a shell. +- Bound output with `max_bytes` and reject unsafe paths, refs, remotes, and + messages before invoking Git. +- Run Git non-interactively: stdin is `/dev/null`, pagers/prompts/editors are + disabled, and every invocation has a bounded timeout. +- Allow guarded local mutations only when intent is explicit and validation + happens before invoking Git. +- Require interactive user approval for every real Git mutation; `dry_run` + calls and read-only inspection never prompt. +- Keep the riskiest local and remote mutations behind an additional + model-visible `confirm=true` intent flag unless the call is a `dry_run`. +- Keep merge and rebase conservative: preview first, clean worktree required, + and first real merge mode limited to `--ff-only`. + +## Scope Status + +The original MVP was read-only inspection. The implementation has deliberately +moved beyond that MVP into guarded local and remote operations because those +actions are needed for a useful branch-management loop. + +This document is the authoritative scope for the Git branch. Older combined +planning text in the context/KV document is historical only after the split: +`feature/agent-git-tools` owns Git support, while +`feature/agent-kv-context-tools` owns context/KV checkpointing. + +## Implemented Actions + +The model-visible DSML schema enumerates the same action set so invalid action +names are rejected before the model has to infer command names from prose. + +Read-only actions include `info`, `status`, `changed_files`, `diff`, `log`, +`show`, `ls_files`, `file_at_ref`, `blame`, `path_history`, `remote_list`, +`merge_base`, `merge_preview`, and `rebase_preview`. + +Guarded local actions include `stage`, `unstage`, `commit`, +`worktree_restore`, `switch`, `stash_push`, `stash_apply`, `stash_pop`, and +`stash_drop`. + +Guarded remote and integration actions include `fetch`, `push`, `merge`, +`merge_abort`, `rebase`, and `rebase_abort`. + +## Guardrails + +- Every Git subprocess defaults to `timeout_sec=30`; callers can request + `timeout_sec` from 1 to 600 seconds. A timeout kills the Git process group and + reports exit code 124 with a timeout notice in the tool output. +- Git runs with terminal prompts disabled (`GIT_TERMINAL_PROMPT=0`), askpass + helpers disabled, merge auto-edit disabled, and `GIT_EDITOR=true`. +- Every real mutating action prompts the local user before invoking Git. + In non-interactive mode, Git mutations are rejected. +- `stage` and `unstage` require either `path` or `all=true`. +- `commit` requires an explicit one-line `message`. +- `worktree_restore` requires either `path` or `all=true`, defaults `ref=HEAD`, + supports `dry_run`, and requires `confirm=true` for a real restore. +- `switch` requires an explicit safe `ref` and `confirm=true` for a real branch + switch; it does not create branches and does not use force, discard, or merge + flags. +- `stash_push` requires an explicit one-line `message`; `all=true` includes + untracked files. +- `stash_show`, `stash_apply`, `stash_pop`, and `stash_drop` accept only safe + stash refs such as `stash@{0}`. +- Real `stash_pop` and `stash_drop` require `confirm=true`; `stash_apply` + supports `dry_run` preview and leaves the stash entry intact. +- `fetch` requires an explicit `remote`; real fetch requires `confirm=true`. +- `push` requires explicit `remote` and `ref`; real push requires + `confirm=true`. Push rejects force, delete, and colon refspec syntax. +- `merge` requires an explicit target ref, `confirm=true` or `dry_run=true`, + and a clean working tree. Real merge is `git merge --ff-only `. +- `rebase` requires an explicit upstream ref, `confirm=true` or `dry_run=true`, + and a clean working tree. +- `merge_abort` and `rebase_abort` require `confirm=true` or `dry_run=true`. +- Paths are passed after `--` where applicable. +- Refs, ranges, remotes, and push refs are rejected when they look like options + or unsupported refspecs. + +## Non-Goals + +- No `reset`. +- No `clean`. +- No raw `checkout`; branch movement uses the guarded `switch` action only. +- No force push. +- No branch/tag deletion. +- No non-fast-forward merge strategy in the first implementation. +- No dependency on context checkpoint or KV-cache internals. diff --git a/ds4_agent.c b/ds4_agent.c index aa08653d..7e32207b 100644 --- a/ds4_agent.c +++ b/ds4_agent.c @@ -1,4 +1,5 @@ #include "ds4.h" +#include "ds4_agent_git.h" #include "ds4_kvstore.h" #include "ds4_web.h" #include "linenoise.h" @@ -123,11 +124,11 @@ typedef struct { size_t out_len; size_t out_cap; ds4_web *web; - bool web_approval_pending; - bool web_approval_answered; - bool web_approval_result; - char web_approval_message[256]; - char web_approval_error[160]; + bool approval_pending; + bool approval_answered; + bool approval_result; + char approval_message[256]; + char approval_error[160]; bool queued_user_drain_pending; bool queued_user_drain_answered; char *queued_user_drain_text; @@ -800,6 +801,42 @@ static const char agent_tools_prompt_after_edit[] = "{\n" " \"type\": \"function\",\n" " \"function\": {\n" + " \"name\": \"git\",\n" + " \"description\": \"Git support: inspection plus guarded local mutations, merge/rebase, and explicit-confirm remote fetch/push. It never resets, force-pushes, or cleans.\",\n" + " \"parameters\": {\n" + " \"type\": \"object\",\n" + " \"properties\": {\n" + " \"action\": {\"type\": \"string\", \"enum\": [\"info\", \"status\", \"changed_files\", \"diff\", \"log\", \"show\", \"ls_files\", \"file_at_ref\", \"blame\", \"path_history\", \"log_path\", \"remote_list\", \"merge_base\", \"merge_preview\", \"rebase_preview\", \"stage\", \"unstage\", \"commit\", \"worktree_restore\", \"switch\", \"stash_list\", \"stash_push\", \"stash_show\", \"stash_apply\", \"stash_pop\", \"stash_drop\", \"fetch\", \"push\", \"merge\", \"merge_abort\", \"rebase\", \"rebase_abort\"]},\n" + " \"repo\": {\"type\": \"string\"},\n" + " \"path\": {\"type\": \"string\"},\n" + " \"ref\": {\"type\": \"string\"},\n" + " \"base_ref\": {\"type\": \"string\"},\n" + " \"target_ref\": {\"type\": \"string\"},\n" + " \"range\": {\"type\": \"string\"},\n" + " \"message\": {\"type\": \"string\"},\n" + " \"remote\": {\"type\": \"string\"},\n" + " \"limit\": {\"type\": \"number\"},\n" + " \"start_line\": {\"type\": \"number\"},\n" + " \"line_count\": {\"type\": \"number\"},\n" + " \"staged\": {\"type\": \"boolean\"},\n" + " \"stat\": {\"type\": \"boolean\"},\n" + " \"name_status\": {\"type\": \"boolean\"},\n" + " \"name_only\": {\"type\": \"boolean\"},\n" + " \"patch\": {\"type\": \"boolean\"},\n" + " \"follow\": {\"type\": \"boolean\"},\n" + " \"dry_run\": {\"type\": \"boolean\"},\n" + " \"all\": {\"type\": \"boolean\"},\n" + " \"confirm\": {\"type\": \"boolean\"},\n" + " \"timeout_sec\": {\"type\": \"number\"},\n" + " \"max_bytes\": {\"type\": \"number\"}\n" + " },\n" + " \"required\": [\"action\"]\n" + " }\n" + " }\n" + "}\n\n" + "{\n" + " \"type\": \"function\",\n" + " \"function\": {\n" " \"name\": \"read\",\n" " \"description\": \"Read a text file or a range of lines.\",\n" " \"parameters\": {\n" @@ -2652,6 +2689,7 @@ static void agent_tool_viz_line_prefix(agent_stream_renderer *sr) { static const char *agent_tool_viz_prefix(const char *name) { if (!strcmp(name, "bash")) return "$ "; + if (!strcmp(name, "git")) return "git "; if (!strcmp(name, "read")) return "read "; if (!strcmp(name, "write")) return "write "; if (!strcmp(name, "edit")) return "edit "; @@ -3812,62 +3850,70 @@ static void agent_publish_system_status(agent_worker *w, const char *msg) { } } -static int agent_web_confirm(void *privdata, const char *message, - char *err, size_t err_len) { - agent_worker *w = privdata; +static int agent_request_user_approval(agent_worker *w, const char *message, + const char *denied_message, + char *err, size_t err_len) { if (!w || w->cfg->non_interactive) { - snprintf(err, err_len, - "visible Chrome browser startup requires interactive approval"); + snprintf(err, err_len, "user approval requires interactive mode"); return 0; } pthread_mutex_lock(&w->mu); - w->web_approval_pending = true; - w->web_approval_answered = false; - w->web_approval_result = false; - w->web_approval_error[0] = '\0'; - snprintf(w->web_approval_message, sizeof(w->web_approval_message), - "%s", message ? message : "Start visible Chrome browser? (y/n) "); + w->approval_pending = true; + w->approval_answered = false; + w->approval_result = false; + w->approval_error[0] = '\0'; + snprintf(w->approval_message, sizeof(w->approval_message), + "%s", message ? message : "Approve action? (y/n) "); agent_wake_locked(w); - while (!w->stop && !w->web_approval_answered) + while (!w->stop && !w->approval_answered) pthread_cond_wait(&w->cond, &w->mu); - bool ok = w->web_approval_result; + bool ok = w->approval_result; if (!ok) { snprintf(err, err_len, "%s", - w->web_approval_error[0] ? w->web_approval_error : - "user denied Chrome browser start"); + w->approval_error[0] ? w->approval_error : + (denied_message && denied_message[0] ? + denied_message : "user denied action")); } pthread_mutex_unlock(&w->mu); return ok ? 1 : 0; } +static int agent_web_confirm(void *privdata, const char *message, + char *err, size_t err_len) { + agent_worker *w = privdata; + return agent_request_user_approval( + w, message ? message : "Start visible Chrome browser? (y/n) ", + "user denied Chrome browser start", err, err_len); +} + static void agent_web_log(void *privdata, const char *message) { agent_worker *w = privdata; if (!w || !message || !message[0]) return; agent_trace(w, "web: %s", message); } -static bool worker_take_web_approval_request(agent_worker *w, - char *message, size_t message_len) { +static bool worker_take_approval_request(agent_worker *w, + char *message, size_t message_len) { pthread_mutex_lock(&w->mu); - bool pending = w->web_approval_pending; + bool pending = w->approval_pending; if (pending) { - snprintf(message, message_len, "%s", w->web_approval_message); - w->web_approval_pending = false; + snprintf(message, message_len, "%s", w->approval_message); + w->approval_pending = false; } pthread_mutex_unlock(&w->mu); return pending; } -static void worker_answer_web_approval(agent_worker *w, bool allow, - const char *deny_error) { +static void worker_answer_approval(agent_worker *w, bool allow, + const char *deny_error) { pthread_mutex_lock(&w->mu); - w->web_approval_result = allow; - w->web_approval_answered = true; + w->approval_result = allow; + w->approval_answered = true; if (!allow) - snprintf(w->web_approval_error, sizeof(w->web_approval_error), + snprintf(w->approval_error, sizeof(w->approval_error), "%s", deny_error && deny_error[0] ? deny_error : - "user denied Chrome browser start"); + "user denied action"); pthread_cond_signal(&w->cond); agent_wake_locked(w); pthread_mutex_unlock(&w->mu); @@ -5468,6 +5514,152 @@ static char *agent_tool_list(const agent_tool_call *call) { return agent_buf_take(&out); } +static bool agent_git_action_mutates(const char *action) { + return action && + (!strcmp(action, "stage") || + !strcmp(action, "unstage") || + !strcmp(action, "commit") || + !strcmp(action, "worktree_restore") || + !strcmp(action, "switch") || + !strcmp(action, "stash_push") || + !strcmp(action, "stash_apply") || + !strcmp(action, "stash_pop") || + !strcmp(action, "stash_drop") || + !strcmp(action, "fetch") || + !strcmp(action, "push") || + !strcmp(action, "merge") || + !strcmp(action, "merge_abort") || + !strcmp(action, "rebase") || + !strcmp(action, "rebase_abort")); +} + +static bool agent_git_action_requires_confirm(const char *action) { + return action && + (!strcmp(action, "worktree_restore") || + !strcmp(action, "switch") || + !strcmp(action, "stash_pop") || + !strcmp(action, "stash_drop") || + !strcmp(action, "fetch") || + !strcmp(action, "push") || + !strcmp(action, "merge") || + !strcmp(action, "merge_abort") || + !strcmp(action, "rebase") || + !strcmp(action, "rebase_abort")); +} + +static bool agent_git_needs_user_approval(const char *action, + bool dry_run, + bool confirm) { + if (dry_run || !agent_git_action_mutates(action)) return false; + if (agent_git_action_requires_confirm(action) && !confirm) return false; + return true; +} + +static char *agent_tool_git(agent_worker *w, const agent_tool_call *call) { + const char *action = agent_tool_arg_value(call, "action"); + const char *repo = agent_tool_arg_value(call, "repo"); + const char *path = agent_tool_arg_value(call, "path"); + const char *ref = agent_tool_arg_value(call, "ref"); + const char *base_ref = agent_tool_arg_value(call, "base_ref"); + const char *target_ref = agent_tool_arg_value(call, "target_ref"); + const char *range = agent_tool_arg_value(call, "range"); + const char *message = agent_tool_arg_value(call, "message"); + const char *remote = agent_tool_arg_value(call, "remote"); + int limit = agent_parse_int_default(agent_tool_arg_value(call, "limit"), + 10, 1, 100); + int start_line = agent_parse_int_default(agent_tool_arg_value(call, "start_line"), + 1, 1, INT_MAX); + int line_count = agent_parse_int_default(agent_tool_arg_value(call, "line_count"), + 80, 1, 1000); + int timeout_sec = agent_parse_int_default(agent_tool_arg_value(call, "timeout_sec"), + 30, 1, 600); + int max_bytes = agent_parse_int_default(agent_tool_arg_value(call, "max_bytes"), + 64 * 1024, 1024, 256 * 1024); + bool staged = agent_parse_bool_default(agent_tool_arg_value(call, "staged"), false); + bool stat = agent_parse_bool_default(agent_tool_arg_value(call, "stat"), false); + bool name_status = agent_parse_bool_default(agent_tool_arg_value(call, "name_status"), false); + bool name_only = agent_parse_bool_default(agent_tool_arg_value(call, "name_only"), false); + bool patch = agent_parse_bool_default(agent_tool_arg_value(call, "patch"), false); + bool follow = agent_parse_bool_default(agent_tool_arg_value(call, "follow"), false); + bool dry_run = agent_parse_bool_default(agent_tool_arg_value(call, "dry_run"), false); + bool all = agent_parse_bool_default(agent_tool_arg_value(call, "all"), false); + bool confirm = agent_parse_bool_default(agent_tool_arg_value(call, "confirm"), false); + + if (agent_git_needs_user_approval(action, dry_run, confirm)) { + char prompt[256]; + snprintf(prompt, sizeof(prompt), + "Approve git %s repo=%s path=%s ref=%s target=%s remote=%s? (y/n) ", + action && action[0] ? action : "action", + repo && repo[0] ? repo : ".", + path && path[0] ? path : (all ? "." : "-"), + ref && ref[0] ? ref : "-", + target_ref && target_ref[0] ? target_ref : "-", + remote && remote[0] ? remote : "-"); + char approval_err[160] = {0}; + if (!agent_request_user_approval(w, prompt, "user denied git action", + approval_err, sizeof(approval_err))) { + agent_buf b = {0}; + agent_buf_puts(&b, "Tool error: git approval failed: "); + agent_buf_puts(&b, approval_err[0] ? approval_err : + "user denied git action"); + agent_buf_puts(&b, "\n"); + return agent_buf_take(&b); + } + } + + ds4_agent_git_options opts = { + .repo = repo && repo[0] ? repo : ".", + .action = action, + .path = path, + .ref = ref, + .base_ref = base_ref, + .target_ref = target_ref, + .range = range, + .message = message, + .remote = remote, + .limit = limit, + .start_line = start_line, + .line_count = line_count, + .timeout_sec = timeout_sec, + .staged = staged, + .stat = stat, + .name_status = name_status, + .name_only = name_only, + .patch = patch, + .follow = follow, + .dry_run = dry_run, + .all = all, + .confirm = confirm, + .max_bytes = (size_t)max_bytes, + }; + ds4_agent_git_result r = {0}; + char err[256] = {0}; + if (!ds4_agent_git_run_options(&opts, &r, err, sizeof(err))) { + agent_buf b = {0}; + agent_buf_puts(&b, "Tool error: git failed: "); + agent_buf_puts(&b, err[0] ? err : "unknown error"); + agent_buf_puts(&b, "\n"); + return agent_buf_take(&b); + } + + agent_buf b = {0}; + char line[256]; + snprintf(line, sizeof(line), "%sgit action=%s exit=%d truncated=%s\n", + r.exit_code == 0 ? "" : "Tool error: ", + action && action[0] ? action : "unknown", + r.exit_code, + r.truncated ? "true" : "false"); + agent_buf_puts(&b, line); + if (r.output && r.output[0]) { + agent_buf_puts(&b, r.output); + if (b.len > 0 && b.ptr[b.len - 1] != '\n') agent_buf_puts(&b, "\n"); + } else { + agent_buf_puts(&b, "(no output)\n"); + } + ds4_agent_git_result_free(&r); + return agent_buf_take(&b); +} + /* ============================================================================ * Edit And Search Tools * ============================================================================ @@ -6699,6 +6891,7 @@ static char *agent_execute_tool_call(agent_worker *w, const agent_tool_call *cal if (!strcmp(call->name, "more")) return agent_tool_more(w, call); if (!strcmp(call->name, "write")) return agent_tool_write(w, call); if (!strcmp(call->name, "list")) return agent_tool_list(call); + if (!strcmp(call->name, "git")) return agent_tool_git(w, call); if (!strcmp(call->name, "edit")) return agent_tool_edit(w, call); if (!strcmp(call->name, "search")) return agent_tool_search(w, call); if (!strcmp(call->name, "google_search")) return agent_tool_google_search(w, call); @@ -9226,9 +9419,9 @@ static int run_agent(ds4_engine *engine, agent_config *cfg) { continue; } - char web_approval_msg[256]; - if (worker_take_web_approval_request(&worker, web_approval_msg, - sizeof(web_approval_msg))) + char approval_msg[256]; + if (worker_take_approval_request(&worker, approval_msg, + sizeof(approval_msg))) { char *saved_input = NULL; if (editor.active && editor.edit.buf && editor.edit.len) @@ -9240,11 +9433,11 @@ static int run_agent(ds4_engine *engine, agent_config *cfg) { .timeout_answer = AGENT_YES_NO_AUTO_NO, }; bool approval_timed_out = false; - bool allow = agent_prompt_yes_no_ex(web_approval_msg, + bool allow = agent_prompt_yes_no_ex(approval_msg, &approval_opts, &approval_timed_out); - worker_answer_web_approval(&worker, allow, - approval_timed_out ? "Chrome browser start approval timed out" : NULL); + worker_answer_approval(&worker, allow, + approval_timed_out ? "approval timed out" : NULL); worker_get_status(&worker, &st); build_prompt_text(&st, prompt, sizeof(prompt)); int restart_cols = editor.edit.cols > 0 ? (int)editor.edit.cols : 80; diff --git a/ds4_agent_git.c b/ds4_agent_git.c new file mode 100644 index 00000000..57cf4c0e --- /dev/null +++ b/ds4_agent_git.c @@ -0,0 +1,1165 @@ +#ifndef _POSIX_C_SOURCE +#define _POSIX_C_SOURCE 200809L +#endif + +#include "ds4_agent_git.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; + +#define DS4_AGENT_GIT_DEFAULT_MAX_BYTES (64 * 1024) +#define DS4_AGENT_GIT_DEFAULT_TIMEOUT_SEC 30 +#define DS4_AGENT_GIT_MAX_TIMEOUT_SEC 600 + +typedef struct { + char *ptr; + size_t len; + size_t cap; +} ds4_agent_git_buf; + +static void git_set_err(char *err, size_t err_len, const char *fmt, ...) { + if (!err || err_len == 0) return; + va_list ap; + va_start(ap, fmt); + vsnprintf(err, err_len, fmt, ap); + va_end(ap); +} + +static void *git_xmalloc(size_t n) { + void *p = malloc(n ? n : 1); + if (!p) { + fprintf(stderr, "ds4-agent-git: out of memory\n"); + abort(); + } + return p; +} + +static void *git_xrealloc(void *ptr, size_t n) { + void *p = realloc(ptr, n ? n : 1); + if (!p) { + fprintf(stderr, "ds4-agent-git: out of memory\n"); + abort(); + } + return p; +} + +static char *git_xstrdup(const char *s) { + if (!s) s = ""; + size_t n = strlen(s); + char *out = git_xmalloc(n + 1); + memcpy(out, s, n + 1); + return out; +} + +static void git_buf_append(ds4_agent_git_buf *b, const char *s, size_t n) { + if (n == 0) return; + if (b->len + n + 1 > b->cap) { + size_t cap = b->cap ? b->cap : 4096; + while (cap < b->len + n + 1) cap *= 2; + b->ptr = git_xrealloc(b->ptr, cap); + b->cap = cap; + } + memcpy(b->ptr + b->len, s, n); + b->len += n; + b->ptr[b->len] = '\0'; +} + +static void git_buf_append_capped(ds4_agent_git_buf *b, const char *s, size_t n, + size_t max_bytes, bool *truncated) { + if (!s || n == 0) return; + if (!max_bytes) max_bytes = DS4_AGENT_GIT_DEFAULT_MAX_BYTES; + if (b->len >= max_bytes) { + if (truncated) *truncated = true; + return; + } + size_t keep = max_bytes - b->len; + if (keep > n) keep = n; + git_buf_append(b, s, keep); + if (keep < n && truncated) *truncated = true; +} + +static void git_buf_puts_capped(ds4_agent_git_buf *b, const char *s, + size_t max_bytes, bool *truncated) { + git_buf_append_capped(b, s ? s : "", s ? strlen(s) : 0, + max_bytes, truncated); +} + +static char *git_buf_take(ds4_agent_git_buf *b) { + if (!b->ptr) return git_xstrdup(""); + char *out = b->ptr; + b->ptr = NULL; + b->len = 0; + b->cap = 0; + return out; +} + +static bool git_ref_safe(const char *ref) { + if (!ref || !ref[0] || ref[0] == '-') return false; + for (const unsigned char *p = (const unsigned char *)ref; *p; p++) { + if (iscntrl(*p) || isspace(*p)) return false; + } + return true; +} + +static void argv_add(const char **argv, int *argc, const char *s) { + argv[(*argc)++] = s; +} + +static int git_argv_base(const char **argv, const char *repo) { + int argc = 0; + if (!repo || !repo[0]) repo = "."; + argv_add(argv, &argc, "git"); + argv_add(argv, &argc, "--no-pager"); + argv_add(argv, &argc, "-c"); + argv_add(argv, &argc, "color.ui=false"); + argv_add(argv, &argc, "-C"); + argv_add(argv, &argc, repo); + return argc; +} + +static int git_timeout_sec(int timeout_sec) { + if (timeout_sec <= 0) return DS4_AGENT_GIT_DEFAULT_TIMEOUT_SEC; + if (timeout_sec > DS4_AGENT_GIT_MAX_TIMEOUT_SEC) + return DS4_AGENT_GIT_MAX_TIMEOUT_SEC; + return timeout_sec; +} + +static long long git_now_ms(void) { + struct timespec ts; + if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) + return (long long)time(NULL) * 1000LL; + return (long long)ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL; +} + +static void git_kill_process_group(pid_t pid) { + if (pid <= 0) return; + kill(-pid, SIGKILL); + kill(pid, SIGKILL); +} + +static void git_sleep_ms(int ms) { + if (ms <= 0) return; + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (long)(ms % 1000) * 1000000L; + while (nanosleep(&ts, &ts) != 0 && errno == EINTR) {} +} + +static void git_child_noninteractive_stdio(int pipe_write_fd) { + close(STDIN_FILENO); + int devnull = open("/dev/null", O_RDONLY); + if (devnull >= 0) { + dup2(devnull, STDIN_FILENO); + if (devnull != STDIN_FILENO) close(devnull); + } + dup2(pipe_write_fd, STDOUT_FILENO); + dup2(pipe_write_fd, STDERR_FILENO); +} + +static bool git_env_entry_has_key(const char *entry, const char *key) { + size_t n = strlen(key); + return entry && !strncmp(entry, key, n) && entry[n] == '='; +} + +static bool git_env_entry_overridden(const char *entry) { + static const char *keys[] = { + "GIT_TERMINAL_PROMPT", + "GIT_ASKPASS", + "SSH_ASKPASS", + "GCM_INTERACTIVE", + "GIT_MERGE_AUTOEDIT", + "GIT_EDITOR", + NULL, + }; + for (int i = 0; keys[i]; i++) { + if (git_env_entry_has_key(entry, keys[i])) return true; + } + return false; +} + +static char **git_child_env_build(void) { + static const char *overrides[] = { + "GIT_TERMINAL_PROMPT=0", + "GIT_ASKPASS=/bin/false", + "SSH_ASKPASS=/bin/false", + "GCM_INTERACTIVE=never", + "GIT_MERGE_AUTOEDIT=no", + "GIT_EDITOR=true", + NULL, + }; + size_t keep_count = 0; + for (char **e = environ; e && *e; e++) { + if (!git_env_entry_overridden(*e)) keep_count++; + } + size_t override_count = 0; + while (overrides[override_count]) override_count++; + + char **env = git_xmalloc((keep_count + override_count + 1) * sizeof(*env)); + size_t j = 0; + for (char **e = environ; e && *e; e++) { + if (!git_env_entry_overridden(*e)) env[j++] = git_xstrdup(*e); + } + for (size_t i = 0; overrides[i]; i++) env[j++] = git_xstrdup(overrides[i]); + env[j] = NULL; + return env; +} + +static void git_child_env_free(char **env) { + if (!env) return; + for (size_t i = 0; env[i]; i++) free(env[i]); + free(env); +} + +static char *git_find_executable(const char *file) { + if (!file || !file[0]) return git_xstrdup(""); + if (strchr(file, '/')) return git_xstrdup(file); + + const char *path = getenv("PATH"); + if (!path || !path[0]) path = "/usr/bin:/bin:/usr/local/bin"; + const char *p = path; + size_t fn = strlen(file); + while (true) { + const char *end = strchr(p, ':'); + size_t dn = end ? (size_t)(end - p) : strlen(p); + size_t dir_len = dn ? dn : 1; + char *candidate = git_xmalloc(dir_len + 1 + fn + 1); + size_t off = 0; + if (dn) { + memcpy(candidate, p, dn); + off = dn; + } else { + candidate[off++] = '.'; + } + candidate[off++] = '/'; + memcpy(candidate + off, file, fn + 1); + if (access(candidate, X_OK) == 0) return candidate; + free(candidate); + if (!end) break; + p = end + 1; + } + return git_xstrdup(file); +} + +static bool run_argv(const char **argv, size_t max_bytes, int timeout_sec, + ds4_agent_git_result *result, + char *err, size_t err_len) { + if (!max_bytes) max_bytes = DS4_AGENT_GIT_DEFAULT_MAX_BYTES; + timeout_sec = git_timeout_sec(timeout_sec); + memset(result, 0, sizeof(*result)); + int pipefd[2]; + if (pipe(pipefd) != 0) { + git_set_err(err, err_len, "pipe: %s", strerror(errno)); + return false; + } + char **child_env = git_child_env_build(); + char *git_exe = git_find_executable("git"); + + pid_t pid = fork(); + if (pid < 0) { + git_set_err(err, err_len, "fork: %s", strerror(errno)); + close(pipefd[0]); + close(pipefd[1]); + git_child_env_free(child_env); + free(git_exe); + return false; + } + + if (pid == 0) { + setpgid(0, 0); + close(pipefd[0]); + git_child_noninteractive_stdio(pipefd[1]); + close(pipefd[1]); + execve(git_exe, (char * const *)argv, child_env); + dprintf(STDERR_FILENO, "exec git: %s\n", strerror(errno)); + _exit(127); + } + + setpgid(pid, pid); + git_child_env_free(child_env); + free(git_exe); + close(pipefd[1]); + int flags = fcntl(pipefd[0], F_GETFL, 0); + if (flags >= 0) fcntl(pipefd[0], F_SETFL, flags | O_NONBLOCK); + ds4_agent_git_buf out = {0}; + char tmp[4096]; + int status = 0; + bool child_done = false; + bool pipe_open = true; + bool timed_out = false; + bool timeout_reported = false; + long long deadline = git_now_ms() + (long long)timeout_sec * 1000LL; + long long timeout_close_deadline = 0; + + while (pipe_open || !child_done) { + if (!child_done) { + pid_t w = waitpid(pid, &status, WNOHANG); + if (w == pid) { + child_done = true; + } else if (w < 0 && errno != EINTR) { + git_set_err(err, err_len, "wait git: %s", strerror(errno)); + if (pipe_open) close(pipefd[0]); + free(out.ptr); + return false; + } + } + + if (pipe_open) { + while (true) { + ssize_t n = read(pipefd[0], tmp, sizeof(tmp)); + if (n < 0 && errno == EINTR) continue; + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) break; + if (n < 0) { + git_set_err(err, err_len, "read git output: %s", strerror(errno)); + close(pipefd[0]); + git_kill_process_group(pid); + int ignored = 0; + waitpid(pid, &ignored, 0); + free(out.ptr); + return false; + } + if (n == 0) { + close(pipefd[0]); + pipe_open = false; + break; + } + size_t got = (size_t)n; + if (out.len < max_bytes) { + size_t keep = max_bytes - out.len; + if (keep > got) keep = got; + git_buf_append(&out, tmp, keep); + } + if (out.len >= max_bytes) result->truncated = true; + } + } + + if (child_done && !pipe_open) break; + + long long now = git_now_ms(); + if (!child_done && !timed_out && now >= deadline) { + timed_out = true; + timeout_close_deadline = now + 1000; + git_kill_process_group(pid); + if (!timeout_reported) { + char msg[128]; + snprintf(msg, sizeof(msg), + "\n[git command timed out after %d seconds]\n", + timeout_sec); + git_buf_append_capped(&out, msg, strlen(msg), max_bytes, + &result->truncated); + timeout_reported = true; + } + } + if (timed_out && pipe_open && timeout_close_deadline > 0 && + now >= timeout_close_deadline) { + close(pipefd[0]); + pipe_open = false; + } + + int wait_ms = 50; + if (!timed_out) { + long long remaining = deadline - now; + if (remaining < 0) remaining = 0; + if (remaining < wait_ms) wait_ms = (int)remaining; + } + if (pipe_open) { + struct pollfd pfd = {.fd = pipefd[0], .events = POLLIN | POLLHUP}; + while (poll(&pfd, 1, wait_ms) < 0 && errno == EINTR) {} + } else if (!child_done && wait_ms > 0) { + git_sleep_ms(wait_ms); + } + } + + if (timed_out) result->exit_code = 124; + else if (WIFEXITED(status)) result->exit_code = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) result->exit_code = 128 + WTERMSIG(status); + else result->exit_code = 1; + + result->output = git_buf_take(&out); + return true; +} + +static bool run_git_tail(const char *repo, const char * const *tail, + size_t max_bytes, int timeout_sec, + ds4_agent_git_result *result, + char *err, size_t err_len) { + const char *argv[64]; + int argc = git_argv_base(argv, repo); + for (int i = 0; tail && tail[i]; i++) argv_add(argv, &argc, tail[i]); + argv[argc] = NULL; + return run_argv(argv, max_bytes, timeout_sec, result, err, err_len); +} + +static char *git_first_line_value(const char *s) { + if (!s) s = ""; + while (*s == '\n' || *s == '\r') s++; + const char *end = s; + while (*end && *end != '\n' && *end != '\r') end++; + while (end > s && isspace((unsigned char)end[-1])) end--; + size_t n = (size_t)(end - s); + char *out = git_xmalloc(n + 1); + memcpy(out, s, n); + out[n] = '\0'; + return out; +} + +static bool git_status_dirty(const char *status) { + const char *p = status ? status : ""; + while (*p) { + const char *line = p; + while (*p && *p != '\n') p++; + size_t len = (size_t)(p - line); + if (len > 0 && !(len >= 3 && line[0] == '#' && line[1] == '#' && line[2] == ' ')) + return true; + if (*p == '\n') p++; + } + return false; +} + +static bool git_info_append_line(const char *repo, const char *key, + const char * const *tail, bool required, + ds4_agent_git_buf *out, size_t max_bytes, + int timeout_sec, + bool *truncated, int *exit_code, + char **line_out, char *err, size_t err_len) { + ds4_agent_git_result tmp = {0}; + if (!run_git_tail(repo, tail, max_bytes, timeout_sec, &tmp, err, err_len)) + return false; + char *line = git_first_line_value(tmp.output); + git_buf_puts_capped(out, key, max_bytes, truncated); + git_buf_puts_capped(out, "=", max_bytes, truncated); + if (tmp.exit_code == 0 && line[0]) { + git_buf_puts_capped(out, line, max_bytes, truncated); + if (line_out) *line_out = git_xstrdup(line); + } else { + git_buf_puts_capped(out, "(none)", max_bytes, truncated); + if (required && exit_code && *exit_code == 0) *exit_code = tmp.exit_code; + } + git_buf_puts_capped(out, "\n", max_bytes, truncated); + if (tmp.exit_code != 0 && required && line[0]) { + git_buf_puts_capped(out, key, max_bytes, truncated); + git_buf_puts_capped(out, "_error=", max_bytes, truncated); + git_buf_puts_capped(out, line, max_bytes, truncated); + git_buf_puts_capped(out, "\n", max_bytes, truncated); + } + if (tmp.truncated && truncated) *truncated = true; + free(line); + ds4_agent_git_result_free(&tmp); + return true; +} + +static bool git_info_append_section(const char *repo, const char *header, + const char * const *tail, bool required, + ds4_agent_git_buf *out, size_t max_bytes, + int timeout_sec, + bool *truncated, int *exit_code, + ds4_agent_git_result *result_out, + char *err, size_t err_len) { + ds4_agent_git_result tmp = {0}; + if (!run_git_tail(repo, tail, max_bytes, timeout_sec, &tmp, err, err_len)) + return false; + if (tmp.exit_code != 0 && required && exit_code && *exit_code == 0) + *exit_code = tmp.exit_code; + git_buf_puts_capped(out, header, max_bytes, truncated); + git_buf_puts_capped(out, "\n", max_bytes, truncated); + if (tmp.output && tmp.output[0]) + git_buf_puts_capped(out, tmp.output, max_bytes, truncated); + else + git_buf_puts_capped(out, "(no output)\n", max_bytes, truncated); + if (out->len > 0 && out->ptr[out->len - 1] != '\n') + git_buf_puts_capped(out, "\n", max_bytes, truncated); + if (tmp.truncated && truncated) *truncated = true; + if (result_out) { + *result_out = tmp; + memset(&tmp, 0, sizeof(tmp)); + } + ds4_agent_git_result_free(&tmp); + return true; +} + +static bool git_run_info(const char *repo, size_t max_bytes, int timeout_sec, + ds4_agent_git_result *result, + char *err, size_t err_len) { + if (!max_bytes) max_bytes = DS4_AGENT_GIT_DEFAULT_MAX_BYTES; + memset(result, 0, sizeof(*result)); + ds4_agent_git_buf out = {0}; + bool truncated = false; + int exit_code = 0; + char *upstream = NULL; + + const char *root_args[] = {"rev-parse", "--show-toplevel", NULL}; + const char *branch_args[] = {"rev-parse", "--abbrev-ref", "HEAD", NULL}; + const char *head_args[] = {"rev-parse", "HEAD", NULL}; + const char *upstream_args[] = { + "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", NULL + }; + if (!git_info_append_line(repo, "repo_root", root_args, true, &out, + max_bytes, timeout_sec, &truncated, &exit_code, NULL, + err, err_len)) + goto fail; + if (!git_info_append_line(repo, "branch", branch_args, true, &out, + max_bytes, timeout_sec, &truncated, &exit_code, NULL, + err, err_len)) + goto fail; + if (!git_info_append_line(repo, "head", head_args, true, &out, + max_bytes, timeout_sec, &truncated, &exit_code, NULL, + err, err_len)) + goto fail; + if (!git_info_append_line(repo, "upstream", upstream_args, false, &out, + max_bytes, timeout_sec, &truncated, &exit_code, &upstream, + err, err_len)) + goto fail; + + if (upstream && upstream[0]) { + const char *ahead_args[] = { + "rev-list", "--left-right", "--count", "@{u}...HEAD", NULL + }; + if (!git_info_append_line(repo, "behind_ahead", ahead_args, false, &out, + max_bytes, timeout_sec, &truncated, &exit_code, NULL, + err, err_len)) + goto fail; + } else { + git_buf_puts_capped(&out, "behind_ahead=(none)\n", max_bytes, &truncated); + } + + const char *status_args[] = {"status", "--porcelain=v1", "--branch", NULL}; + ds4_agent_git_result status = {0}; + if (!git_info_append_section(repo, "status:", status_args, true, &out, + max_bytes, timeout_sec, &truncated, &exit_code, &status, + err, err_len)) + goto fail; + git_buf_puts_capped(&out, "dirty=", max_bytes, &truncated); + git_buf_puts_capped(&out, git_status_dirty(status.output) ? "true\n" : "false\n", + max_bytes, &truncated); + ds4_agent_git_result_free(&status); + + const char *remote_args[] = {"remote", "-v", NULL}; + if (!git_info_append_section(repo, "remotes:", remote_args, false, &out, + max_bytes, timeout_sec, &truncated, &exit_code, NULL, + err, err_len)) + goto fail; + + result->output = git_buf_take(&out); + result->exit_code = exit_code; + result->truncated = truncated; + free(upstream); + return true; + +fail: + free(out.ptr); + free(upstream); + return false; +} + +void ds4_agent_git_result_free(ds4_agent_git_result *r) { + if (!r) return; + free(r->output); + memset(r, 0, sizeof(*r)); +} + +static bool git_optional_ref_safe(const char *name, const char *ref, + char *err, size_t err_len) { + if (!ref || !ref[0]) return true; + if (git_ref_safe(ref)) return true; + git_set_err(err, err_len, "unsafe git %s: %s", name, ref); + return false; +} + +static bool git_tree_ref_safe(const char *name, const char *ref, + char *err, size_t err_len) { + if (!git_optional_ref_safe(name, ref, err, err_len)) return false; + if (ref && strchr(ref, ':')) { + git_set_err(err, err_len, "unsafe git %s: %s", name, ref); + return false; + } + return true; +} + +static bool git_path_required(const char *path, const char *action, + char *err, size_t err_len) { + if (!path || !path[0]) { + git_set_err(err, err_len, "git %s requires path", action); + return false; + } + for (const unsigned char *p = (const unsigned char *)path; *p; p++) { + if (iscntrl(*p)) { + git_set_err(err, err_len, "unsafe git path for %s", action); + return false; + } + } + return true; +} + +static bool git_path_or_all_required(const ds4_agent_git_options *opts, + char *err, size_t err_len) { + if (opts->all) return true; + return git_path_required(opts->path, opts->action, err, err_len); +} + +static bool git_message_valid(const char *what, const char *message, + char *err, size_t err_len) { + if (!message || !message[0]) { + git_set_err(err, err_len, "git %s requires message", what); + return false; + } + size_t n = strlen(message); + if (n > 256) { + git_set_err(err, err_len, "git %s message is too long", what); + return false; + } + for (const unsigned char *p = (const unsigned char *)message; *p; p++) { + if (iscntrl(*p)) { + git_set_err(err, err_len, "git %s message must be one line", what); + return false; + } + } + return true; +} + +static bool git_stash_ref_safe(const char *ref, char *err, size_t err_len) { + if (!ref || !ref[0]) return true; + if (!git_ref_safe(ref) || + strncmp(ref, "stash@{", strlen("stash@{")) != 0 || + ref[strlen(ref) - 1] != '}') { + git_set_err(err, err_len, "unsafe git stash ref: %s", ref); + return false; + } + return true; +} + +static bool git_remote_safe(const char *remote, char *err, size_t err_len) { + if (!remote || !remote[0]) { + git_set_err(err, err_len, "git remote is required"); + return false; + } + if (remote[0] == '-') { + git_set_err(err, err_len, "unsafe git remote: %s", remote); + return false; + } + for (const unsigned char *p = (const unsigned char *)remote; *p; p++) { + if (iscntrl(*p) || isspace(*p)) { + git_set_err(err, err_len, "unsafe git remote: %s", remote); + return false; + } + } + return true; +} + +static bool git_push_ref_safe(const char *ref, char *err, size_t err_len) { + if (!ref || !ref[0]) { + git_set_err(err, err_len, "git push requires ref"); + return false; + } + if (!git_ref_safe(ref) || strchr(ref, ':')) { + git_set_err(err, err_len, "unsafe git push ref: %s", ref); + return false; + } + return true; +} + +static bool git_confirmed_or_dry_run(const ds4_agent_git_options *opts, + char *err, size_t err_len) { + if (opts->dry_run || opts->confirm) return true; + git_set_err(err, err_len, "git %s requires confirm=true or dry_run=true", + opts->action ? opts->action : "action"); + return false; +} + +static bool git_required_ref(const char *action, const char *name, + const char *ref, char *err, size_t err_len) { + if (!ref || !ref[0]) { + git_set_err(err, err_len, "git %s requires %s", action, name); + return false; + } + return git_optional_ref_safe(name, ref, err, err_len); +} + +static bool git_require_clean_worktree(const char *repo, const char *action, + int timeout_sec, + char *err, size_t err_len) { + const char *status_args[] = {"status", "--porcelain=v1", NULL}; + ds4_agent_git_result st = {0}; + bool ok = run_git_tail(repo, status_args, DS4_AGENT_GIT_DEFAULT_MAX_BYTES, + timeout_sec, &st, err, err_len); + if (!ok) return false; + if (st.exit_code != 0) { + char *line = git_first_line_value(st.output); + git_set_err(err, err_len, "git %s clean check failed: %s", + action, line[0] ? line : "status failed"); + free(line); + ds4_agent_git_result_free(&st); + return false; + } + if (st.output && st.output[0]) { + git_set_err(err, err_len, "git %s requires a clean working tree", action); + ds4_agent_git_result_free(&st); + return false; + } + ds4_agent_git_result_free(&st); + return true; +} + +static char *git_blob_spec(const char *ref, const char *path) { + size_t rn = strlen(ref); + size_t pn = strlen(path); + char *out = git_xmalloc(rn + 1 + pn + 1); + memcpy(out, ref, rn); + out[rn] = ':'; + memcpy(out + rn + 1, path, pn + 1); + return out; +} + +bool ds4_agent_git_run_options(const ds4_agent_git_options *opts, + ds4_agent_git_result *result, + char *err, + size_t err_len) { + if (!result) { + git_set_err(err, err_len, "git result is required"); + return false; + } + if (!opts || !opts->action || !opts->action[0]) { + git_set_err(err, err_len, "git action is required"); + return false; + } + + const char *repo = opts->repo && opts->repo[0] ? opts->repo : "."; + const char *action = opts->action; + if (!strcmp(action, "info")) + return git_run_info(repo, opts->max_bytes, opts->timeout_sec, + result, err, err_len); + + const char *argv[64]; + int argc = git_argv_base(argv, repo); + + char limit_arg[64]; + char range_arg[1024]; + char line_arg[64]; + char *owned_arg = NULL; + if (!strcmp(action, "status")) { + argv_add(argv, &argc, "status"); + argv_add(argv, &argc, "--porcelain=v1"); + argv_add(argv, &argc, "--branch"); + } else if (!strcmp(action, "merge_base")) { + const char *base = opts->base_ref && opts->base_ref[0] ? + opts->base_ref : "HEAD"; + const char *target = opts->target_ref && opts->target_ref[0] ? + opts->target_ref : opts->ref; + if (!git_required_ref(action, "base_ref", base, err, err_len) || + !git_required_ref(action, "target_ref", target, err, err_len)) + return false; + argv_add(argv, &argc, "merge-base"); + argv_add(argv, &argc, base); + argv_add(argv, &argc, target); + } else if (!strcmp(action, "merge_preview")) { + const char *target = opts->target_ref && opts->target_ref[0] ? + opts->target_ref : opts->ref; + if (!git_required_ref(action, "target_ref", target, err, err_len)) + return false; + argv_add(argv, &argc, "merge-tree"); + argv_add(argv, &argc, "--write-tree"); + argv_add(argv, &argc, "--messages"); + argv_add(argv, &argc, "HEAD"); + argv_add(argv, &argc, target); + } else if (!strcmp(action, "merge")) { + const char *target = opts->target_ref && opts->target_ref[0] ? + opts->target_ref : opts->ref; + if (!git_required_ref(action, "target_ref", target, err, err_len) || + !git_confirmed_or_dry_run(opts, err, err_len)) + return false; + if (opts->dry_run) { + argv_add(argv, &argc, "merge-tree"); + argv_add(argv, &argc, "--write-tree"); + argv_add(argv, &argc, "--messages"); + argv_add(argv, &argc, "HEAD"); + argv_add(argv, &argc, target); + } else { + if (!git_require_clean_worktree(repo, action, opts->timeout_sec, + err, err_len)) + return false; + argv_add(argv, &argc, "merge"); + argv_add(argv, &argc, "--ff-only"); + argv_add(argv, &argc, target); + } + } else if (!strcmp(action, "merge_abort")) { + if (!git_confirmed_or_dry_run(opts, err, err_len)) return false; + if (opts->dry_run) { + argv_add(argv, &argc, "status"); + argv_add(argv, &argc, "--porcelain=v1"); + argv_add(argv, &argc, "--branch"); + } else { + argv_add(argv, &argc, "merge"); + argv_add(argv, &argc, "--abort"); + } + } else if (!strcmp(action, "rebase_preview")) { + const char *upstream = opts->base_ref && opts->base_ref[0] ? + opts->base_ref : opts->ref; + if (!git_required_ref(action, "upstream", upstream, err, err_len)) + return false; + argv_add(argv, &argc, "log"); + argv_add(argv, &argc, "--oneline"); + argv_add(argv, &argc, "--decorate"); + snprintf(range_arg, sizeof(range_arg), "%s..HEAD", upstream); + argv_add(argv, &argc, range_arg); + } else if (!strcmp(action, "rebase")) { + const char *upstream = opts->base_ref && opts->base_ref[0] ? + opts->base_ref : opts->ref; + if (!git_required_ref(action, "upstream", upstream, err, err_len) || + !git_confirmed_or_dry_run(opts, err, err_len)) + return false; + if (opts->dry_run) { + argv_add(argv, &argc, "log"); + argv_add(argv, &argc, "--oneline"); + argv_add(argv, &argc, "--decorate"); + snprintf(range_arg, sizeof(range_arg), "%s..HEAD", upstream); + argv_add(argv, &argc, range_arg); + } else { + if (!git_require_clean_worktree(repo, action, opts->timeout_sec, + err, err_len)) + return false; + argv_add(argv, &argc, "rebase"); + argv_add(argv, &argc, upstream); + } + } else if (!strcmp(action, "rebase_abort")) { + if (!git_confirmed_or_dry_run(opts, err, err_len)) return false; + if (opts->dry_run) { + argv_add(argv, &argc, "status"); + argv_add(argv, &argc, "--porcelain=v1"); + argv_add(argv, &argc, "--branch"); + } else { + argv_add(argv, &argc, "rebase"); + argv_add(argv, &argc, "--abort"); + } + } else if (!strcmp(action, "remote_list")) { + argv_add(argv, &argc, "remote"); + argv_add(argv, &argc, "-v"); + } else if (!strcmp(action, "changed_files")) { + argv_add(argv, &argc, "status"); + argv_add(argv, &argc, "--porcelain=v1"); + argv_add(argv, &argc, "-uall"); + argv_add(argv, &argc, "--"); + if (opts->path && opts->path[0]) argv_add(argv, &argc, opts->path); + } else if (!strcmp(action, "file_at_ref")) { + const char *ref = opts->ref && opts->ref[0] ? opts->ref : "HEAD"; + if (!git_path_required(opts->path, action, err, err_len) || + !git_tree_ref_safe("ref", ref, err, err_len)) + return false; + owned_arg = git_blob_spec(ref, opts->path); + argv_add(argv, &argc, "show"); + argv_add(argv, &argc, "--no-ext-diff"); + argv_add(argv, &argc, owned_arg); + } else if (!strcmp(action, "stage")) { + if (!git_path_or_all_required(opts, err, err_len)) return false; + if (opts->dry_run) { + argv_add(argv, &argc, "status"); + argv_add(argv, &argc, "--porcelain=v1"); + argv_add(argv, &argc, "-uall"); + if (!opts->all) { + argv_add(argv, &argc, "--"); + argv_add(argv, &argc, opts->path); + } + } else { + argv_add(argv, &argc, "add"); + if (opts->all) argv_add(argv, &argc, "-A"); + argv_add(argv, &argc, "--"); + argv_add(argv, &argc, opts->all ? "." : opts->path); + } + } else if (!strcmp(action, "unstage")) { + if (!git_path_or_all_required(opts, err, err_len)) return false; + if (opts->dry_run) { + argv_add(argv, &argc, "diff"); + argv_add(argv, &argc, "--cached"); + argv_add(argv, &argc, "--name-status"); + if (!opts->all) { + argv_add(argv, &argc, "--"); + argv_add(argv, &argc, opts->path); + } + } else { + argv_add(argv, &argc, "restore"); + argv_add(argv, &argc, "--staged"); + argv_add(argv, &argc, "--"); + argv_add(argv, &argc, opts->all ? "." : opts->path); + } + } else if (!strcmp(action, "commit")) { + if (!git_message_valid("commit", opts->message, err, err_len)) return false; + if (opts->dry_run) { + argv_add(argv, &argc, "diff"); + argv_add(argv, &argc, "--cached"); + argv_add(argv, &argc, "--stat"); + } else { + argv_add(argv, &argc, "commit"); + argv_add(argv, &argc, "-m"); + argv_add(argv, &argc, opts->message); + } + } else if (!strcmp(action, "worktree_restore")) { + const char *ref = opts->ref && opts->ref[0] ? opts->ref : "HEAD"; + if (!git_path_or_all_required(opts, err, err_len) || + !git_tree_ref_safe("ref", ref, err, err_len) || + !git_confirmed_or_dry_run(opts, err, err_len)) + return false; + if (opts->dry_run) { + argv_add(argv, &argc, "diff"); + argv_add(argv, &argc, "--name-status"); + argv_add(argv, &argc, ref); + argv_add(argv, &argc, "--"); + argv_add(argv, &argc, opts->all ? "." : opts->path); + } else { + argv_add(argv, &argc, "restore"); + argv_add(argv, &argc, "--source"); + argv_add(argv, &argc, ref); + argv_add(argv, &argc, "--"); + argv_add(argv, &argc, opts->all ? "." : opts->path); + } + } else if (!strcmp(action, "switch")) { + const char *ref = opts->ref; + if (!ref || !ref[0]) { + git_set_err(err, err_len, "git switch requires ref"); + return false; + } + if (!git_optional_ref_safe("ref", ref, err, err_len) || + !git_confirmed_or_dry_run(opts, err, err_len)) + return false; + if (opts->dry_run) { + argv_add(argv, &argc, "rev-parse"); + argv_add(argv, &argc, "--verify"); + argv_add(argv, &argc, ref); + } else { + argv_add(argv, &argc, "switch"); + argv_add(argv, &argc, "--no-guess"); + argv_add(argv, &argc, ref); + } + } else if (!strcmp(action, "stash_list")) { + int limit = opts->limit; + if (limit <= 0) limit = 20; + if (limit > 100) limit = 100; + snprintf(limit_arg, sizeof(limit_arg), "-%d", limit); + argv_add(argv, &argc, "stash"); + argv_add(argv, &argc, "list"); + argv_add(argv, &argc, limit_arg); + } else if (!strcmp(action, "stash_push")) { + if (!git_message_valid("stash_push", opts->message, err, err_len)) + return false; + if (opts->dry_run) { + argv_add(argv, &argc, "status"); + argv_add(argv, &argc, "--porcelain=v1"); + argv_add(argv, &argc, "-uall"); + if (opts->path && opts->path[0]) { + argv_add(argv, &argc, "--"); + argv_add(argv, &argc, opts->path); + } + } else { + argv_add(argv, &argc, "stash"); + argv_add(argv, &argc, "push"); + argv_add(argv, &argc, "--message"); + argv_add(argv, &argc, opts->message); + if (opts->all) argv_add(argv, &argc, "--include-untracked"); + if (opts->path && opts->path[0]) { + argv_add(argv, &argc, "--"); + argv_add(argv, &argc, opts->path); + } + } + } else if (!strcmp(action, "stash_show")) { + const char *ref = opts->ref && opts->ref[0] ? opts->ref : "stash@{0}"; + if (!git_stash_ref_safe(ref, err, err_len)) return false; + argv_add(argv, &argc, "stash"); + argv_add(argv, &argc, "show"); + if (opts->patch) argv_add(argv, &argc, "--patch"); + else argv_add(argv, &argc, "--stat"); + argv_add(argv, &argc, ref); + } else if (!strcmp(action, "stash_apply") || + !strcmp(action, "stash_pop") || + !strcmp(action, "stash_drop")) { + const char *ref = opts->ref && opts->ref[0] ? opts->ref : "stash@{0}"; + if (!git_stash_ref_safe(ref, err, err_len)) return false; + if (strcmp(action, "stash_apply") && + !git_confirmed_or_dry_run(opts, err, err_len)) + return false; + if (opts->dry_run) { + argv_add(argv, &argc, "stash"); + argv_add(argv, &argc, "show"); + argv_add(argv, &argc, "--stat"); + argv_add(argv, &argc, ref); + } else { + argv_add(argv, &argc, "stash"); + if (!strcmp(action, "stash_apply")) argv_add(argv, &argc, "apply"); + else if (!strcmp(action, "stash_pop")) argv_add(argv, &argc, "pop"); + else argv_add(argv, &argc, "drop"); + argv_add(argv, &argc, ref); + } + } else if (!strcmp(action, "fetch")) { + if (!git_remote_safe(opts->remote, err, err_len) || + !git_optional_ref_safe("ref", opts->ref, err, err_len) || + !git_confirmed_or_dry_run(opts, err, err_len)) + return false; + argv_add(argv, &argc, "fetch"); + argv_add(argv, &argc, "--prune"); + if (opts->dry_run) argv_add(argv, &argc, "--dry-run"); + argv_add(argv, &argc, opts->remote); + if (opts->ref && opts->ref[0]) argv_add(argv, &argc, opts->ref); + } else if (!strcmp(action, "push")) { + if (!git_remote_safe(opts->remote, err, err_len) || + !git_push_ref_safe(opts->ref, err, err_len) || + !git_confirmed_or_dry_run(opts, err, err_len)) + return false; + argv_add(argv, &argc, "push"); + if (opts->dry_run) argv_add(argv, &argc, "--dry-run"); + argv_add(argv, &argc, opts->remote); + argv_add(argv, &argc, opts->ref); + } else if (!strcmp(action, "blame")) { + const char *ref = opts->ref && opts->ref[0] ? opts->ref : "HEAD"; + int start = opts->start_line > 0 ? opts->start_line : 1; + int count = opts->line_count > 0 ? opts->line_count : 80; + if (count > 1000) count = 1000; + if (!git_path_required(opts->path, action, err, err_len) || + !git_optional_ref_safe("ref", ref, err, err_len)) + return false; + snprintf(line_arg, sizeof(line_arg), "%d,+%d", start, count); + argv_add(argv, &argc, "blame"); + argv_add(argv, &argc, "--no-progress"); + argv_add(argv, &argc, "--date=short"); + argv_add(argv, &argc, "-L"); + argv_add(argv, &argc, line_arg); + argv_add(argv, &argc, ref); + argv_add(argv, &argc, "--"); + argv_add(argv, &argc, opts->path); + } else if (!strcmp(action, "path_history") || !strcmp(action, "log_path")) { + const char *ref = opts->ref; + int limit = opts->limit; + if (limit <= 0) limit = 20; + if (limit > 100) limit = 100; + if (!git_path_required(opts->path, action, err, err_len) || + !git_optional_ref_safe("ref", ref, err, err_len)) + return false; + snprintf(limit_arg, sizeof(limit_arg), "--max-count=%d", limit); + argv_add(argv, &argc, "log"); + if (opts->follow) argv_add(argv, &argc, "--follow"); + argv_add(argv, &argc, "--oneline"); + argv_add(argv, &argc, "--decorate"); + argv_add(argv, &argc, "--name-status"); + argv_add(argv, &argc, limit_arg); + if (ref && ref[0]) argv_add(argv, &argc, ref); + argv_add(argv, &argc, "--"); + argv_add(argv, &argc, opts->path); + } else if (!strcmp(action, "diff")) { + if (opts->name_only && opts->name_status) { + git_set_err(err, err_len, "diff cannot use both name_only and name_status"); + return false; + } + if (opts->range && opts->range[0] && + ((opts->base_ref && opts->base_ref[0]) || + (opts->target_ref && opts->target_ref[0]))) { + git_set_err(err, err_len, "diff range cannot be combined with base_ref or target_ref"); + return false; + } + if (opts->staged && + ((opts->range && opts->range[0]) || + (opts->base_ref && opts->base_ref[0]) || + (opts->target_ref && opts->target_ref[0]))) { + git_set_err(err, err_len, "staged diff cannot be combined with refs or range"); + return false; + } + if (!git_optional_ref_safe("range", opts->range, err, err_len) || + !git_optional_ref_safe("base_ref", opts->base_ref, err, err_len) || + !git_optional_ref_safe("target_ref", opts->target_ref, err, err_len)) + return false; + + const char *range = opts->range; + if ((!range || !range[0]) && opts->base_ref && opts->base_ref[0]) { + const char *target = opts->target_ref && opts->target_ref[0] ? + opts->target_ref : "HEAD"; + size_t need = strlen(opts->base_ref) + strlen(target) + 4; + if (need > sizeof(range_arg)) { + git_set_err(err, err_len, "git diff range is too long"); + return false; + } + snprintf(range_arg, sizeof(range_arg), "%s...%s", + opts->base_ref, target); + range = range_arg; + } else if ((!range || !range[0]) && opts->target_ref && opts->target_ref[0]) { + range = opts->target_ref; + } + + argv_add(argv, &argc, "diff"); + argv_add(argv, &argc, "--no-ext-diff"); + argv_add(argv, &argc, "--find-renames"); + if (opts->staged) argv_add(argv, &argc, "--cached"); + if (opts->stat) argv_add(argv, &argc, "--stat"); + if (opts->name_status) argv_add(argv, &argc, "--name-status"); + if (opts->name_only) argv_add(argv, &argc, "--name-only"); + if (opts->patch) argv_add(argv, &argc, "--patch"); + if (range && range[0]) argv_add(argv, &argc, range); + argv_add(argv, &argc, "--"); + if (opts->path && opts->path[0]) argv_add(argv, &argc, opts->path); + } else if (!strcmp(action, "log")) { + int limit = opts->limit; + if (limit <= 0) limit = 10; + if (limit > 100) limit = 100; + snprintf(limit_arg, sizeof(limit_arg), "--max-count=%d", limit); + argv_add(argv, &argc, "log"); + argv_add(argv, &argc, "--oneline"); + argv_add(argv, &argc, "--decorate"); + argv_add(argv, &argc, limit_arg); + argv_add(argv, &argc, "--"); + if (opts->path && opts->path[0]) argv_add(argv, &argc, opts->path); + } else if (!strcmp(action, "show")) { + const char *ref = opts->ref; + if (!ref || !ref[0]) ref = "HEAD"; + if (!git_ref_safe(ref)) { + git_set_err(err, err_len, "unsafe git ref: %s", ref ? ref : ""); + return false; + } + argv_add(argv, &argc, "show"); + if (opts->stat || !opts->patch) argv_add(argv, &argc, "--stat"); + if (opts->patch) argv_add(argv, &argc, "--patch"); + argv_add(argv, &argc, "--oneline"); + argv_add(argv, &argc, "--decorate"); + argv_add(argv, &argc, "--no-ext-diff"); + argv_add(argv, &argc, ref); + argv_add(argv, &argc, "--"); + if (opts->path && opts->path[0]) argv_add(argv, &argc, opts->path); + } else if (!strcmp(action, "ls_files")) { + argv_add(argv, &argc, "ls-files"); + argv_add(argv, &argc, "--"); + if (opts->path && opts->path[0]) argv_add(argv, &argc, opts->path); + } else { + git_set_err(err, err_len, "unknown git action: %s", action); + return false; + } + argv[argc] = NULL; + bool ok = run_argv(argv, opts->max_bytes, opts->timeout_sec, + result, err, err_len); + free(owned_arg); + return ok; +} + +bool ds4_agent_git_run(const char *repo, + const char *action, + const char *path, + const char *ref, + int limit, + bool staged, + size_t max_bytes, + ds4_agent_git_result *result, + char *err, + size_t err_len) { + ds4_agent_git_options opts = { + .repo = repo, + .action = action, + .path = path, + .ref = ref, + .limit = limit, + .staged = staged, + .max_bytes = max_bytes, + }; + return ds4_agent_git_run_options(&opts, result, err, err_len); +} diff --git a/ds4_agent_git.h b/ds4_agent_git.h new file mode 100644 index 00000000..bc1f0710 --- /dev/null +++ b/ds4_agent_git.h @@ -0,0 +1,57 @@ +#ifndef DS4_AGENT_GIT_H +#define DS4_AGENT_GIT_H + +#include +#include + +typedef struct ds4_agent_git_result { + char *output; + int exit_code; + bool truncated; +} ds4_agent_git_result; + +typedef struct ds4_agent_git_options { + const char *repo; + const char *action; + const char *path; + const char *ref; + const char *base_ref; + const char *target_ref; + const char *range; + const char *message; + const char *remote; + int limit; + int start_line; + int line_count; + int timeout_sec; + bool staged; + bool stat; + bool name_status; + bool name_only; + bool patch; + bool follow; + bool dry_run; + bool all; + bool confirm; + size_t max_bytes; +} ds4_agent_git_options; + +void ds4_agent_git_result_free(ds4_agent_git_result *r); + +bool ds4_agent_git_run_options(const ds4_agent_git_options *opts, + ds4_agent_git_result *result, + char *err, + size_t err_len); + +bool ds4_agent_git_run(const char *repo, + const char *action, + const char *path, + const char *ref, + int limit, + bool staged, + size_t max_bytes, + ds4_agent_git_result *result, + char *err, + size_t err_len); + +#endif diff --git a/tests/ds4_agent_git_test.c b/tests/ds4_agent_git_test.c new file mode 100644 index 00000000..7ba0ec22 --- /dev/null +++ b/tests/ds4_agent_git_test.c @@ -0,0 +1,945 @@ +#ifndef _POSIX_C_SOURCE +#define _POSIX_C_SOURCE 200809L +#endif + +#include "../ds4_agent_git.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void test_fail(const char *msg) { + fprintf(stderr, "ds4_agent_git_test: %s\n", msg); + exit(1); +} + +#define CHECK(cond, msg) do { if (!(cond)) test_fail(msg); } while (0) + +static char *test_strdup(const char *s) { + size_t n = strlen(s); + char *out = malloc(n + 1); + CHECK(out != NULL, "malloc failed"); + memcpy(out, s, n + 1); + return out; +} + +static char *make_temp_dir(void) { + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "/tmp"; + for (int i = 0; i < 100; i++) { + char path[PATH_MAX]; + snprintf(path, sizeof(path), "%s/ds4-agent-git-test-%ld-%ld-%d", + base, (long)getpid(), (long)time(NULL), i); + if (mkdir(path, 0700) == 0) return test_strdup(path); + if (errno != EEXIST) break; + } + test_fail("failed to create temp dir"); + return NULL; +} + +static void run_cmd(char *const argv[]) { + pid_t pid = fork(); + CHECK(pid >= 0, "fork failed"); + if (pid == 0) { + execvp(argv[0], argv); + _exit(127); + } + int status = 0; + while (waitpid(pid, &status, 0) < 0) { + if (errno == EINTR) continue; + test_fail("waitpid failed"); + } + CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0, "command failed"); +} + +static void write_file(const char *path, const char *text) { + FILE *fp = fopen(path, "wb"); + CHECK(fp != NULL, "failed to open file"); + CHECK(fwrite(text, 1, strlen(text), fp) == strlen(text), "failed to write file"); + CHECK(fclose(fp) == 0, "failed to close file"); +} + +static char *join_path(const char *dir, const char *file) { + char path[PATH_MAX]; + snprintf(path, sizeof(path), "%s/%s", dir, file); + return test_strdup(path); +} + +static void remove_tree(const char *path) { + struct stat st; + if (lstat(path, &st) != 0) return; + if (S_ISDIR(st.st_mode)) { + DIR *d = opendir(path); + if (d) { + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) continue; + char *child = join_path(path, de->d_name); + remove_tree(child); + free(child); + } + closedir(d); + } + rmdir(path); + } else { + unlink(path); + } +} + +static void assert_git_ok(const char *repo, const char *action, + const char *path, const char *ref, + int limit, bool staged, + ds4_agent_git_result *r) { + char err[256] = {0}; + CHECK(ds4_agent_git_run(repo, action, path, ref, limit, staged, + 64 * 1024, r, err, sizeof(err)), + "git helper failed to run"); + CHECK(r->exit_code == 0, "git command returned non-zero"); +} + +static void assert_git_opts_ok(const ds4_agent_git_options *opts, + ds4_agent_git_result *r) { + ds4_agent_git_options copy = *opts; + if (!copy.max_bytes) copy.max_bytes = 64 * 1024; + char err[256] = {0}; + CHECK(ds4_agent_git_run_options(©, r, err, sizeof(err)), + "git helper options failed to run"); + CHECK(r->exit_code == 0, "git options command returned non-zero"); +} + +int main(void) { + char *repo = make_temp_dir(); + char *remote_dir = make_temp_dir(); + char *file = join_path(repo, "a.txt"); + char *untracked = join_path(repo, "b.txt"); + char *stash_file = join_path(repo, "c.txt"); + char *merge_file = join_path(repo, "d.txt"); + char *topic_file = join_path(repo, "e.txt"); + char *base_file = join_path(repo, "f.txt"); + + char *init_argv[] = {"git", "-C", repo, "init", "-q", NULL}; + char *config_name_argv[] = {"git", "-C", repo, "config", "user.name", "DS4 Test", NULL}; + char *config_email_argv[] = {"git", "-C", repo, "config", "user.email", "ds4@example.invalid", NULL}; + char *bare_init_argv[] = {"git", "-C", remote_dir, "init", "--bare", "-q", NULL}; + run_cmd(init_argv); + run_cmd(config_name_argv); + run_cmd(config_email_argv); + run_cmd(bare_init_argv); + + write_file(file, "one\n"); + char *add_argv[] = {"git", "-C", repo, "add", "a.txt", NULL}; + char *commit_argv[] = {"git", "-C", repo, "commit", "-q", "-m", "initial", NULL}; + run_cmd(add_argv); + run_cmd(commit_argv); + + write_file(file, "two\n"); + write_file(untracked, "new\n"); + + ds4_agent_git_result r = {0}; + char err[256] = {0}; + ds4_agent_git_options opts = {.repo = repo, .action = "info"}; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "repo_root=") != NULL, "info missing repo root"); + CHECK(strstr(r.output, "branch=") != NULL, "info missing branch"); + CHECK(strstr(r.output, "head=") != NULL, "info missing head"); + CHECK(strstr(r.output, "dirty=true") != NULL, "info missing dirty state"); + CHECK(strstr(r.output, "a.txt") != NULL, "info status missing modified file"); + ds4_agent_git_result_free(&r); + + assert_git_ok(repo, "status", NULL, NULL, 0, false, &r); + CHECK(strstr(r.output, "a.txt") != NULL, "status missing modified file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){.repo = repo, .action = "changed_files"}; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, " M a.txt") != NULL, "changed_files missing modified file"); + CHECK(strstr(r.output, "?? b.txt") != NULL, "changed_files missing untracked file"); + ds4_agent_git_result_free(&r); + + assert_git_ok(repo, "diff", "a.txt", NULL, 0, false, &r); + CHECK(strstr(r.output, "-one") != NULL, "diff missing removed line"); + CHECK(strstr(r.output, "+two") != NULL, "diff missing added line"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "diff", + .path = "a.txt", + .name_status = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "M\ta.txt") != NULL, "name-status diff missing modified file"); + CHECK(strstr(r.output, "-one") == NULL, "name-status diff unexpectedly included patch"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "diff", + .path = "a.txt", + .stat = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "a.txt") != NULL, "stat diff missing modified file"); + CHECK(strstr(r.output, "-one") == NULL, "stat diff unexpectedly included patch"); + ds4_agent_git_result_free(&r); + + assert_git_ok(repo, "ls_files", NULL, NULL, 0, false, &r); + CHECK(strstr(r.output, "a.txt") != NULL, "ls_files missing tracked file"); + ds4_agent_git_result_free(&r); + + assert_git_ok(repo, "log", NULL, NULL, 1, false, &r); + CHECK(strstr(r.output, "initial") != NULL, "log missing commit subject"); + ds4_agent_git_result_free(&r); + + assert_git_ok(repo, "show", NULL, "HEAD", 0, false, &r); + CHECK(strstr(r.output, "initial") != NULL, "show missing commit subject"); + CHECK(strstr(r.output, "a.txt") != NULL, "show missing file stat"); + ds4_agent_git_result_free(&r); + + run_cmd(add_argv); + assert_git_ok(repo, "diff", "a.txt", NULL, 0, true, &r); + CHECK(strstr(r.output, "-one") != NULL, "staged diff missing removed line"); + CHECK(strstr(r.output, "+two") != NULL, "staged diff missing added line"); + ds4_agent_git_result_free(&r); + + char *commit_update_argv[] = {"git", "-C", repo, "commit", "-q", "-m", "update a", NULL}; + run_cmd(commit_update_argv); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "show", + .ref = "HEAD", + .patch = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "update a") != NULL, "show patch missing commit subject"); + CHECK(strstr(r.output, "-one") != NULL, "show patch missing removed line"); + CHECK(strstr(r.output, "+two") != NULL, "show patch missing added line"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "diff", + .range = "HEAD~1...HEAD", + .name_status = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "M\ta.txt") != NULL, "range diff missing modified file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "diff", + .base_ref = "HEAD~1", + .target_ref = "HEAD", + .name_only = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "a.txt") != NULL, "base/target diff missing file"); + CHECK(strstr(r.output, "-one") == NULL, "name-only diff unexpectedly included patch"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "file_at_ref", + .ref = "HEAD~1", + .path = "a.txt", + }; + assert_git_opts_ok(&opts, &r); + CHECK(!strcmp(r.output, "one\n"), "file_at_ref returned wrong historical content"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "blame", + .ref = "HEAD", + .path = "a.txt", + .start_line = 1, + .line_count = 1, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "two") != NULL, "blame missing requested line"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "path_history", + .path = "a.txt", + .limit = 5, + .follow = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "update a") != NULL, "path_history missing update commit"); + CHECK(strstr(r.output, "initial") != NULL, "path_history missing initial commit"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "stage", + .path = "b.txt", + .dry_run = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "?? b.txt") != NULL, "stage dry-run missing candidate file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "diff", + .path = "b.txt", + .staged = true, + .name_only = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "b.txt") == NULL, "stage dry-run unexpectedly staged file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "stage", + .path = "b.txt", + }; + assert_git_opts_ok(&opts, &r); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "diff", + .path = "b.txt", + .staged = true, + .name_status = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "A\tb.txt") != NULL, "stage did not stage new file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "unstage", + .path = "b.txt", + .dry_run = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "A\tb.txt") != NULL, "unstage dry-run missing staged file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "unstage", + .path = "b.txt", + }; + assert_git_opts_ok(&opts, &r); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){.repo = repo, .action = "changed_files", .path = "b.txt"}; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "?? b.txt") != NULL, "unstage did not unstage file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){.repo = repo, .action = "stage", .path = "b.txt"}; + assert_git_opts_ok(&opts, &r); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "commit", + .message = "add b", + .dry_run = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "b.txt") != NULL, "commit dry-run missing staged summary"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "commit", + .message = "add b", + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "add b") != NULL, "commit missing commit subject"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){.repo = repo, .action = "log", .limit = 1}; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "add b") != NULL, "log missing new commit"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "file_at_ref", + .ref = "HEAD", + .path = "b.txt", + }; + assert_git_opts_ok(&opts, &r); + CHECK(!strcmp(r.output, "new\n"), "commit did not preserve staged file content"); + ds4_agent_git_result_free(&r); + + write_file(file, "three\n"); + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "worktree_restore", + .path = "a.txt", + .dry_run = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "M\ta.txt") != NULL, "worktree_restore dry-run missing modified file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){.repo = repo, .action = "worktree_restore", .path = "a.txt"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "worktree_restore should require confirm=true or dry_run=true"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "worktree_restore", + .path = "a.txt", + .confirm = true, + }; + assert_git_opts_ok(&opts, &r); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "changed_files", + .path = "a.txt", + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "a.txt") == NULL, "worktree_restore left tracked file dirty"); + ds4_agent_git_result_free(&r); + + char *branch_argv[] = {"git", "-C", repo, "branch", "side", NULL}; + run_cmd(branch_argv); + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "switch", + .ref = "side", + .dry_run = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strlen(r.output) >= 40, "switch dry-run did not resolve target"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){.repo = repo, .action = "switch", .ref = "side"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "switch should require confirm=true or dry_run=true"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "switch", + .ref = "side", + .confirm = true, + }; + assert_git_opts_ok(&opts, &r); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){.repo = repo, .action = "status"}; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "## side") != NULL, "switch did not move to side branch"); + ds4_agent_git_result_free(&r); + + char *remote_add_argv[] = {"git", "-C", repo, "remote", "add", "local", remote_dir, NULL}; + run_cmd(remote_add_argv); + opts = (ds4_agent_git_options){.repo = repo, .action = "remote_list"}; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "local") != NULL, "remote_list missing remote name"); + CHECK(strstr(r.output, remote_dir) != NULL, "remote_list missing remote path"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "push", + .remote = "local", + .ref = "side", + .dry_run = true, + }; + assert_git_opts_ok(&opts, &r); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "push", + .remote = "local", + .ref = "side", + }; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "push should require confirm=true or dry_run=true"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "push", + .remote = "local", + .ref = "side", + .confirm = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "side") != NULL, "push missing pushed ref"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "fetch", + .remote = "local", + .ref = "side", + .dry_run = true, + }; + assert_git_opts_ok(&opts, &r); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "fetch", + .remote = "local", + .ref = "side", + }; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "fetch should require confirm=true or dry_run=true"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "fetch", + .remote = "local", + .ref = "side", + .confirm = true, + }; + assert_git_opts_ok(&opts, &r); + ds4_agent_git_result_free(&r); + + char *merge_branch_argv[] = {"git", "-C", repo, "switch", "-q", "-c", "merge_src", NULL}; + run_cmd(merge_branch_argv); + write_file(merge_file, "merge\n"); + char *add_merge_argv[] = {"git", "-C", repo, "add", "d.txt", NULL}; + char *commit_merge_argv[] = {"git", "-C", repo, "commit", "-q", "-m", "merge source", NULL}; + run_cmd(add_merge_argv); + run_cmd(commit_merge_argv); + char *switch_side_argv[] = {"git", "-C", repo, "switch", "-q", "side", NULL}; + run_cmd(switch_side_argv); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "merge_base", + .base_ref = "side", + .target_ref = "merge_src", + }; + assert_git_opts_ok(&opts, &r); + CHECK(strlen(r.output) >= 40, "merge_base did not return an oid"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "merge_preview", + .target_ref = "merge_src", + }; + assert_git_opts_ok(&opts, &r); + CHECK(strlen(r.output) >= 40, "merge_preview did not return a merge tree"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "merge", + .target_ref = "merge_src", + }; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "merge should require confirm=true or dry_run=true"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "merge", + .target_ref = "merge_src", + .dry_run = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strlen(r.output) >= 40, "merge dry-run did not return a merge tree"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "merge", + .target_ref = "merge_src", + .confirm = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "Fast-forward") != NULL, "merge did not fast-forward"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "file_at_ref", + .ref = "HEAD", + .path = "d.txt", + }; + assert_git_opts_ok(&opts, &r); + CHECK(!strcmp(r.output, "merge\n"), "merge did not bring target file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "merge_abort", + .dry_run = true, + }; + assert_git_opts_ok(&opts, &r); + ds4_agent_git_result_free(&r); + + char *topic_branch_argv[] = {"git", "-C", repo, "switch", "-q", "-c", "topic", NULL}; + run_cmd(topic_branch_argv); + write_file(topic_file, "topic\n"); + char *add_topic_argv[] = {"git", "-C", repo, "add", "e.txt", NULL}; + char *commit_topic_argv[] = {"git", "-C", repo, "commit", "-q", "-m", "topic commit", NULL}; + run_cmd(add_topic_argv); + run_cmd(commit_topic_argv); + run_cmd(switch_side_argv); + write_file(base_file, "base\n"); + char *add_base_argv[] = {"git", "-C", repo, "add", "f.txt", NULL}; + char *commit_base_argv[] = {"git", "-C", repo, "commit", "-q", "-m", "base advance", NULL}; + run_cmd(add_base_argv); + run_cmd(commit_base_argv); + char *switch_topic_argv[] = {"git", "-C", repo, "switch", "-q", "topic", NULL}; + run_cmd(switch_topic_argv); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "rebase_preview", + .ref = "side", + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "topic commit") != NULL, "rebase_preview missing topic commit"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){.repo = repo, .action = "rebase", .ref = "side"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "rebase should require confirm=true or dry_run=true"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "rebase", + .ref = "side", + .dry_run = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "topic commit") != NULL, "rebase dry-run missing topic commit"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "rebase", + .ref = "side", + .confirm = true, + }; + assert_git_opts_ok(&opts, &r); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){.repo = repo, .action = "file_at_ref", .ref = "HEAD", .path = "e.txt"}; + assert_git_opts_ok(&opts, &r); + CHECK(!strcmp(r.output, "topic\n"), "rebase lost topic file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){.repo = repo, .action = "file_at_ref", .ref = "HEAD", .path = "f.txt"}; + assert_git_opts_ok(&opts, &r); + CHECK(!strcmp(r.output, "base\n"), "rebase did not include upstream file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "rebase_abort", + .dry_run = true, + }; + assert_git_opts_ok(&opts, &r); + ds4_agent_git_result_free(&r); + + write_file(file, "stashed\n"); + write_file(stash_file, "extra\n"); + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "stash_push", + .message = "save local state", + .all = true, + .dry_run = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "M a.txt") != NULL, "stash dry-run missing tracked file"); + CHECK(strstr(r.output, "?? c.txt") != NULL, "stash dry-run missing untracked file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "stash_push", + .message = "save local state", + .all = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "save local state") != NULL, "stash_push missing stash message"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){.repo = repo, .action = "changed_files"}; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "a.txt") == NULL, "stash_push left tracked file dirty"); + CHECK(strstr(r.output, "c.txt") == NULL, "stash_push left untracked file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){.repo = repo, .action = "stash_list", .limit = 5}; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "save local state") != NULL, "stash_list missing saved state"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "stash_show", + .ref = "stash@{0}", + .patch = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "+stashed") != NULL, "stash_show patch missing tracked change"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "stash_apply", + .ref = "stash@{0}", + .dry_run = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "a.txt") != NULL, "stash_apply dry-run missing staged summary"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "stash_apply", + .ref = "stash@{0}", + }; + assert_git_opts_ok(&opts, &r); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){.repo = repo, .action = "changed_files"}; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, " M a.txt") != NULL, "stash_apply did not restore tracked change"); + CHECK(strstr(r.output, "?? c.txt") != NULL, "stash_apply did not restore untracked file"); + ds4_agent_git_result_free(&r); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "stash_drop", + .ref = "stash@{0}", + }; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "stash_drop should require confirm=true or dry_run=true"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "stash_pop", + .ref = "stash@{0}", + }; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "stash_pop should require confirm=true or dry_run=true"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "stash_drop", + .ref = "stash@{0}", + .confirm = true, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "Dropped") != NULL, "stash_drop did not drop stash"); + ds4_agent_git_result_free(&r); + + CHECK(!ds4_agent_git_run(repo, "show", NULL, "--stat", 0, false, + 64 * 1024, &r, err, sizeof(err)), + "unsafe ref should be rejected before git runs"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "file_at_ref", + .ref = "HEAD:a.txt", + .path = "a.txt", + }; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "file_at_ref should reject refs containing a tree separator"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "blame", + .ref = "--reverse", + .path = "a.txt", + }; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "blame should reject unsafe refs"); + + opts = (ds4_agent_git_options){.repo = repo, .action = "stage"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "stage should require path or all=true"); + + opts = (ds4_agent_git_options){.repo = repo, .action = "unstage"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "unstage should require path or all=true"); + + opts = (ds4_agent_git_options){.repo = repo, .action = "commit"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "commit should require message"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "commit", + .message = "bad\nmessage", + }; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "commit should reject multiline messages"); + + opts = (ds4_agent_git_options){.repo = repo, .action = "worktree_restore"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "worktree_restore should require path or all=true"); + + opts = (ds4_agent_git_options){.repo = repo, .action = "switch"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "switch should require ref"); + + opts = (ds4_agent_git_options){.repo = repo, .action = "switch", .ref = "--detach"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "switch should reject unsafe refs"); + + opts = (ds4_agent_git_options){.repo = repo, .action = "stash_push"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "stash_push should require message"); + + opts = (ds4_agent_git_options){.repo = repo, .action = "merge"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "merge should require target ref"); + + opts = (ds4_agent_git_options){.repo = repo, .action = "merge", .target_ref = "--strategy"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "merge should reject unsafe target refs"); + + opts = (ds4_agent_git_options){.repo = repo, .action = "rebase"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "rebase should require upstream ref"); + + opts = (ds4_agent_git_options){.repo = repo, .action = "rebase", .ref = "--onto"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "rebase should reject unsafe upstream refs"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "stash_show", + .ref = "HEAD", + }; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "stash_show should reject non-stash refs"); + + opts = (ds4_agent_git_options){.repo = repo, .action = "fetch"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "fetch should require remote"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "fetch", + .remote = "--upload-pack", + .dry_run = true, + }; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "fetch should reject unsafe remotes"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "push", + .remote = "local", + .ref = ":side", + .dry_run = true, + }; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "push should reject delete refspecs"); + + opts = (ds4_agent_git_options){.repo = repo, .action = "diff", .range = "--stat"}; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "unsafe range should be rejected before git runs"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "diff", + .name_only = true, + .name_status = true, + }; + CHECK(!ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "conflicting diff formats should be rejected"); + + char *fake_dir = make_temp_dir(); + char *fake_git = join_path(fake_dir, "git"); + const char *current_path = getenv("PATH"); + bool had_path = current_path != NULL; + char *old_path = current_path ? test_strdup(current_path) : NULL; + size_t new_path_len = strlen(fake_dir) + 1 + (old_path ? strlen(old_path) : 0) + 1; + char *new_path = malloc(new_path_len); + CHECK(new_path != NULL, "malloc failed"); + if (old_path && old_path[0]) + snprintf(new_path, new_path_len, "%s:%s", fake_dir, old_path); + else + snprintf(new_path, new_path_len, "%s", fake_dir); + + write_file(fake_git, + "#!/bin/sh\n" + "printf 'prompt=%s askpass=%s ssh=%s editor=%s autoedit=%s gcm=%s\\n' " + "\"$GIT_TERMINAL_PROMPT\" \"$GIT_ASKPASS\" \"$SSH_ASKPASS\" " + "\"$GIT_EDITOR\" \"$GIT_MERGE_AUTOEDIT\" \"$GCM_INTERACTIVE\"\n"); + CHECK(chmod(fake_git, 0700) == 0, "failed to chmod fake git"); + CHECK(setenv("PATH", new_path, 1) == 0, "failed to override PATH"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "status", + .timeout_sec = 5, + }; + assert_git_opts_ok(&opts, &r); + CHECK(strstr(r.output, "prompt=0") != NULL, "git should disable terminal prompts"); + CHECK(strstr(r.output, "askpass=/bin/false") != NULL, "git should disable askpass"); + CHECK(strstr(r.output, "ssh=/bin/false") != NULL, "git should disable ssh askpass"); + CHECK(strstr(r.output, "editor=true") != NULL, "git should use a noninteractive editor"); + CHECK(strstr(r.output, "autoedit=no") != NULL, "git should disable merge autoedit"); + CHECK(strstr(r.output, "gcm=never") != NULL, "git should disable credential manager prompts"); + ds4_agent_git_result_free(&r); + + write_file(fake_git, + "#!/bin/sh\n" + "sleep 2\n" + "echo late\n"); + CHECK(chmod(fake_git, 0700) == 0, "failed to chmod timeout fake git"); + + opts = (ds4_agent_git_options){ + .repo = repo, + .action = "status", + .timeout_sec = 1, + }; + err[0] = '\0'; + CHECK(ds4_agent_git_run_options(&opts, &r, err, sizeof(err)), + "timeout fake git should execute"); + CHECK(r.exit_code == 124, "timed out git command should report exit code 124"); + CHECK(strstr(r.output, "timed out after 1 seconds") != NULL, + "timed out git command should report timeout"); + CHECK(strstr(r.output, "late") == NULL, + "timed out git command should kill the process group"); + ds4_agent_git_result_free(&r); + + if (had_path) + CHECK(setenv("PATH", old_path, 1) == 0, "failed to restore PATH"); + else + CHECK(unsetenv("PATH") == 0, "failed to unset PATH"); + remove_tree(fake_dir); + free(new_path); + free(old_path); + free(fake_git); + free(fake_dir); + + remove_tree(repo); + remove_tree(remote_dir); + free(base_file); + free(topic_file); + free(merge_file); + free(stash_file); + free(untracked); + free(file); + free(remote_dir); + free(repo); + return 0; +}