Merge pull request #7259 from libgit2/ethomson/commi_create

CLI: commit command
This commit is contained in:
Edward Thomson
2026-06-01 22:55:53 +01:00
committed by GitHub
10 changed files with 650 additions and 10 deletions

View File

@@ -28,6 +28,7 @@ extern const cli_cmd_spec *cli_cmd_spec_byname(const char *name);
extern int cmd_blame(int argc, char **argv);
extern int cmd_cat_file(int argc, char **argv);
extern int cmd_clone(int argc, char **argv);
extern int cmd_commit(int argc, char **argv);
extern int cmd_config(int argc, char **argv);
extern int cmd_hash_object(int argc, char **argv);
extern int cmd_help(int argc, char **argv);

View File

@@ -70,7 +70,7 @@ static int fmt_date(git_str *out, git_time_t time, int offset)
time_t t;
struct tm gmt;
GIT_ASSERT_ARG(out);
CLI_ASSERT(out);
t = (time_t)(time + offset * 60);

View File

@@ -91,7 +91,7 @@ static int print_odb(git_object *object, display_t display)
ret = cli_error_os();
break;
default:
GIT_ASSERT(0);
CLI_ASSERT(!"invalid display type");
}
done:

621
src/cli/cmd_commit.c Normal file
View File

@@ -0,0 +1,621 @@
/*
* Copyright (C) the libgit2 contributors. All rights reserved.
*
* This file is part of libgit2, distributed under the GNU GPL v2 with
* a Linking Exception. For full terms see the included COPYING file.
*/
#include <git2.h>
#include "common.h"
#include "cmd.h"
#include "futils.h"
#include "process.h"
#define COMMAND_NAME "commit"
static char unset_keyid[] = { 0 };
static char *message;
static char *template;
static int amend;
static int allow_empty;
static int allow_empty_message;
static char *keyid = unset_keyid;
static int no_sign;
static const cli_opt_spec opts[] = {
CLI_COMMON_OPT,
{ CLI_OPT_TYPE_VALUE, NULL, 'm', &message, 0,
CLI_OPT_USAGE_DEFAULT, "message", "specify the commit message" },
{ CLI_OPT_TYPE_VALUE, "template", 't', &template, 0,
CLI_OPT_USAGE_DEFAULT, "file", "the original text for the commit message" },
{ CLI_OPT_TYPE_SWITCH, "amend", 0, &amend, 1,
0, NULL, "allow commits that have no changes" },
{ CLI_OPT_TYPE_SWITCH, "allow-empty", 0, &allow_empty, 1,
0, NULL, "allow commits that have no changes" },
{ CLI_OPT_TYPE_SWITCH, "allow-empty-message", 0, &allow_empty_message, 1,
0, NULL, "allow commit message" },
{ CLI_OPT_TYPE_VALUE, "gpg-sign", 'S', &keyid, 0,
CLI_OPT_USAGE_VALUE_OPTIONAL, "keyid", "sign the commit" },
{ CLI_OPT_TYPE_SWITCH, "no-gpg-sign", 0, &no_sign, 1,
CLI_OPT_USAGE_CHOICE, NULL, "do not sign the commit" },
{ 0 },
};
#define MESSAGE_INSTRUCTIONS \
"# Please enter the commit message for your changes. Lines starting\n" \
"# with '#' will be ignored, and an empty message aborts the commit.\n"
static void print_help(void)
{
cli_opt_usage_fprint(stdout, PROGRAM_NAME, COMMAND_NAME, opts, 0);
printf("\n");
printf("Commit the current contents of the index.\n");
printf("\n");
printf("Options:\n");
cli_opt_help_fprint(stdout, opts);
}
static int check_empty_commit(git_status_list *status)
{
const git_status_entry *entry;
size_t entries, i;
if (amend || allow_empty)
return 0;
entries = git_status_list_entrycount(status);
for (i = 0; i < entries; i++) {
entry = git_status_byindex(status, i);
if (entry->head_to_index != NULL)
return 0;
}
return cli_error("no changes added to commit (use \"git add\" and/or \"git commit -a\")");
}
static int prepare_amend_message(
git_str *message,
git_reference *head_ref)
{
git_commit *head_commit;
int ret = 0;
if (git_reference_peel((git_object **)&head_commit, head_ref, GIT_OBJECT_COMMIT) < 0) {
ret = cli_error_git();
goto done;
}
git_str_puts(message, git_commit_message(head_commit));
done:
git_commit_free(head_commit);
return ret;
}
static int prepare_template(git_str *message, git_repository *repo)
{
git_config *config = NULL;
git_buf config_value = GIT_BUF_INIT;
int error, ret = 0;
if (!template) {
if (git_repository_config(&config, repo) < 0) {
ret = cli_error_git();
goto done;
}
error = git_config_get_string_buf(&config_value,
config, "commit.template");
if (error && error != GIT_ENOTFOUND) {
ret = cli_error_git();
goto done;
} else if (error != GIT_ENOTFOUND) {
template = config_value.ptr;
}
}
if (template) {
if (git_futils_readbuffer(message, template) < 0) {
ret = cli_error_git();
goto done;
}
}
done:
git_buf_dispose(&config_value);
git_config_free(config);
return ret;
}
static int prepare_changes(
git_str *message,
git_status_list *status)
{
const git_status_entry *entry;
const git_diff_delta *delta;
const char *change_type, *file;
bool staged_header = false, unstaged_header = false,
untracked_header = false;
size_t entries, i;
entries = git_status_list_entrycount(status);
for (i = 0; i < entries; i++) {
entry = git_status_byindex(status, i);
if (!(delta = entry->head_to_index))
continue;
if (!staged_header) {
git_str_puts(message, "# Changes to be committed:\n");
staged_header = true;
}
switch (delta->status) {
case GIT_DELTA_ADDED:
change_type = "new file:";
file = delta->new_file.path;
break;
case GIT_DELTA_DELETED:
change_type = "deleted:";
file = delta->old_file.path;
break;
case GIT_DELTA_MODIFIED:
change_type = "modified:";
file = delta->old_file.path;
break;
case GIT_DELTA_RENAMED:
change_type = "renamed:";
file = delta->old_file.path;
break;
default:
CLI_ASSERT(!"unhandled head->index delta type");
break;
}
git_str_printf(message, "#\t%-11s %s", change_type, file);
if (delta->status == GIT_DELTA_RENAMED)
git_str_printf(message, " -> %s", delta->new_file.path);
git_str_printf(message, "\n");
}
for (i = 0; i < entries; i++) {
entry = git_status_byindex(status, i);
if (!(delta = entry->index_to_workdir) ||
delta->status == GIT_DELTA_UNTRACKED)
continue;
if (!unstaged_header) {
if (staged_header)
git_str_puts(message, "#\n");
git_str_puts(message, "# Changes not staged for commit:\n");
unstaged_header = true;
}
switch (delta->status) {
case GIT_DELTA_DELETED:
change_type = "deleted:";
file = delta->old_file.path;
break;
case GIT_DELTA_MODIFIED:
change_type = "modified:";
file = delta->old_file.path;
break;
default:
printf("%d\n", delta->status);
CLI_ASSERT(!"unhandled workdir->index delta type");
break;
}
git_str_printf(message, "# %-11s %s", change_type, file);
if (delta->status == GIT_DELTA_RENAMED)
git_str_printf(message, " -> %s", delta->new_file.path);
git_str_printf(message, "\n");
}
for (i = 0; i < entries; i++) {
entry = git_status_byindex(status, i);
if (!(delta = entry->index_to_workdir) ||
delta->status != GIT_DELTA_UNTRACKED)
continue;
if (!untracked_header) {
if (staged_header || unstaged_header)
git_str_puts(message, "#\n");
git_str_puts(message, "# Untracked files:\n");
untracked_header = true;
}
git_str_printf(message, "# %s\n", delta->new_file.path);
}
if (git_str_oom(message))
return cli_error_git();
return 0;
}
static int prepare_initial_message(
git_str *message,
git_repository *repo,
git_status_list *status)
{
git_reference *head_ref = NULL;
const char *branch_name = NULL;
int ret;
if (git_repository_head(&head_ref, repo) < 0) {
ret = cli_error_git();
goto done;
}
if (git_branch_name(&branch_name, head_ref) < 0)
git_error_clear();
if (amend)
ret = prepare_amend_message(message, head_ref);
else
ret = prepare_template(message, repo);
if (ret < 0)
goto done;
git_str_puts(message, "\n" MESSAGE_INSTRUCTIONS "#\n");
if (branch_name)
git_str_printf(message, "# On branch %s\n", branch_name);
else
git_str_printf(message, "# Not currently on any branch.\n");
ret = prepare_changes(message, status);
done:
git_reference_free(head_ref);
return ret;
}
static int should_sign(bool *out, git_repository *repo)
{
git_config *config = NULL;
int sign, error, ret = 0;
if (no_sign) {
*out = false;
return 0;
}
if (keyid != unset_keyid) {
*out = true;
return 0;
}
if (git_repository_config(&config, repo) < 0) {
ret = cli_error_git();
goto done;
}
error = git_config_get_bool(&sign, config, "commit.gpgsign");
if (error == GIT_ENOTFOUND)
*out = false;
else
*out = !!sign;
done:
git_config_free(config);
return ret;
}
static int lookup_editor(char **out, git_repository *repo)
{
git_str env = GIT_STR_INIT;
git_str term = GIT_STR_INIT;
git_buf buf = GIT_BUF_INIT;
git_config *config = NULL;
bool is_dumb = true;
int ret = 0;
if (git__getenv(&env, "GIT_EDITOR") == 0) {
*out = git_str_detach(&env);
goto done;
}
if (git_repository_config(&config, repo) < 0) {
ret = cli_error_git();
goto done;
}
if (git_config_get_string_buf(&buf, config, "core.editor") == 0) {
*out = git__strdup(buf.ptr);
if (*out == NULL)
ret = -1;
goto done;
}
if (git__getenv(&term, "TERM") == 0)
is_dumb = (git__strcmp(term.ptr, "dumb") == 0);
if ((!is_dumb && git__getenv(&env, "VISUAL") == 0) ||
git__getenv(&env, "EDITOR") == 0) {
*out = git_str_detach(&env);
goto done;
}
if (is_dumb) {
ret = cli_error("no EDITOR is specified");
} else {
*out = git__strdup("vi");
ret = *out == NULL ? -1 : 0;
}
done:
git_buf_dispose(&buf);
git_str_dispose(&term);
git_str_dispose(&env);
git_config_free(config);
return ret;
}
static int read_commit_message(
git_buf *out,
git_repository *repo,
git_status_list *status)
{
git_process *process = NULL;
git_process_result result = GIT_PROCESS_RESULT_INIT;
char *editor = NULL;
git_str cmdline = GIT_STR_INIT;
git_str message_file = GIT_STR_INIT;
git_str initial = GIT_STR_INIT;
git_str message = GIT_STR_INIT;
int ret = 0;
if ((ret = prepare_initial_message(&initial, repo, status)) != 0)
return ret;
if (git_str_joinpath(&message_file,
git_repository_path(repo),
"COMMIT_EDITMSG") < 0 ||
git_futils_writebuffer(&initial, message_file.ptr,
O_CREAT|O_TRUNC|O_WRONLY, 0666) < 0) {
ret = cli_error_git();
goto done;
}
if ((ret = lookup_editor(&editor, repo)) != 0)
goto done;
if (git_str_printf(&cmdline, "%s \"%s\"",
editor, message_file.ptr) < 0 ||
git_process_new_from_cmdline(&process, cmdline.ptr,
NULL, 0, NULL) < 0 ||
git_process_start(process) < 0 ||
git_process_wait(&result, process) < 0) {
ret = cli_error_git();
goto done;
}
if (result.status != GIT_PROCESS_STATUS_NORMAL ||
result.exitcode != 0) {
ret = cli_error("could not invoke '%s': exit code %d",
editor, result.exitcode);
ret = 1;
goto done;
}
if (git_futils_readbuffer(&message, message_file.ptr) < 0 ||
git_message_prettify(out, message.ptr, 1, '#') < 0) {
ret = cli_error_git();
goto done;
}
ret = 0;
done:
git_process_free(process);
git_str_dispose(&message);
git_str_dispose(&cmdline);
git_str_dispose(&message_file);
git_str_dispose(&initial);
git__free(editor);
return ret;
}
static int sign_commit(
git_commitbuilder *builder,
git_repository *repo,
const char *commit_content,
void *payload)
{
git_process *process = NULL;
git_config *config = NULL;
git_buf config_value = GIT_BUF_INIT;
git_str committer_ident = GIT_STR_INIT, cmdline = GIT_STR_INIT,
signature = GIT_STR_INIT;
const char *args[3] = { "gpg", "-bsau", NULL };
git_process_result result = GIT_PROCESS_RESULT_INIT;
git_process_options process_opts = GIT_PROCESS_OPTIONS_INIT;
git_signature *committer = NULL;
char buf[16];
ssize_t read_len;
int ret = 0;
GIT_UNUSED(builder);
GIT_UNUSED(commit_content);
GIT_UNUSED(payload);
if (git_repository_config(&config, repo) < 0) {
ret = cli_error_git();
goto done;
}
if (git_config_get_string_buf(&config_value, config, "gpg.program") == 0)
args[0] = config_value.ptr;
if (keyid && keyid != unset_keyid) {
args[2] = keyid;
} else {
if (git_signature_default_from_env(NULL, &committer, repo) < 0 ||
git_str_printf(&committer_ident, "%s <%s>", committer->name, committer->email) < 0) {
ret = cli_error_git();
goto done;
}
args[2] = committer_ident.ptr;
}
process_opts.capture_in = 1;
process_opts.capture_out = 1;
if (git_process_new(&process, args, 3, NULL, 0, &process_opts) < 0 ||
git_process_start(process) < 0 ||
git_process_write(process, commit_content, strlen(commit_content)) < 0) {
ret = cli_error_git();
goto done;
}
git_process_close_in(process);
while ((read_len = git_process_read(process, buf, sizeof(buf))) > 0) {
if (git_str_put(&signature, buf, (size_t)read_len) < 0) {
ret = cli_error_git();
goto done;
}
}
if (read_len < 0) {
ret = cli_error_git();
goto done;
}
if (git_process_wait(&result, process) < 0 ||
git_commitbuilder_add_header(builder, "gpgsig", signature.ptr) < 0) {
ret = cli_error_git();
goto done;
}
ret = result.exitcode == 0 ? 0 : GIT_EUSER;
done:
git_signature_free(committer);
git_buf_dispose(&config_value);
git_str_dispose(&cmdline);
git_str_dispose(&committer_ident);
git_str_dispose(&signature);
git_process_close(process);
git_process_free(process);
return ret;
}
int cmd_commit(int argc, char **argv)
{
cli_repository_open_options open_opts = { argv + 1, argc - 1};
git_status_options status_opts = GIT_STATUS_OPTIONS_INIT;
git_commit_create_options commit_opts = GIT_COMMIT_CREATE_OPTIONS_INIT;
git_repository *repo = NULL;
git_index *index = NULL;
git_status_list *status = NULL;
git_tree *tree = NULL;
git_oid commit_id, tree_id;
git_buf read_message = GIT_BUF_INIT;
cli_opt invalid_opt;
bool sign = false;
int error, ret = 0;
if (cli_opt_parse(&invalid_opt, opts, argv + 1, argc - 1, CLI_OPT_PARSE_GNU))
return cli_opt_usage_error(COMMAND_NAME, opts, &invalid_opt);
status_opts.flags |= GIT_STATUS_OPT_RENAMES_HEAD_TO_INDEX;
status_opts.flags |= GIT_STATUS_OPT_INCLUDE_UNTRACKED;
/* We check for empty commits; don't duplicate the check. */
commit_opts.allow_empty_commit = true;
commit_opts.payload = repo;
if (cli_opt__show_help) {
print_help();
return 0;
}
if (cli_repository_open(&repo, &open_opts) < 0 ||
git_repository_index(&index, repo) < 0) {
ret = cli_error_git();
goto done;
}
if ((ret = should_sign(&sign, repo)) != 0)
goto done;
if (sign)
commit_opts.sign = sign_commit;
if (git_status_list_new(&status, repo, &status_opts) < 0) {
ret = cli_error_git();
goto done;
}
if ((ret = check_empty_commit(status)) != 0)
goto done;
if (git_index_write_tree(&tree_id, index) < 0 ||
git_tree_lookup(&tree, repo, &tree_id) < 0) {
ret = cli_error_git();
goto done;
}
if (!message) {
if ((ret = read_commit_message(&read_message,
repo, status)) != 0)
goto done;
message = read_message.ptr;
}
if (!*message && !allow_empty_message) {
ret = cli_error("no commit message specified (use \"--allow-empty-message\")");
goto done;
}
if (amend)
error = git_commit_amend_from_tree(&commit_id,
repo, tree, message, &commit_opts);
else
error = git_commit_create_from_tree(&commit_id,
repo, tree, message, &commit_opts);
if (error == GIT_EUSER)
ret = CLI_EXIT_GIT;
else if (error < 0)
ret = cli_error_git();
done:
git_buf_dispose(&read_message);
git_status_list_free(status);
git_index_free(index);
git_repository_free(repo);
return ret;
}

View File

@@ -48,4 +48,17 @@ GIT_INLINE(int) cli_error_git(void)
#define cli_error_os() (perror(PROGRAM_NAME), CLI_EXIT_OS)
#if (defined(_DEBUG) || defined(GIT_ASSERT_HARD)) && GIT_ASSERT_HARD != 0
# include <assert.h>
# define CLI_ASSERT(expr) assert(expr)
#else
# define CLI_ASSERT(expr) do { \
if (!(expr)) { \
fprintf(stderr, "%s: assertion failed: '%s'\n", \
PROGRAM_NAME, #expr); \
exit(255); \
} \
} while(0)
#endif
#endif /* CLI_error_h__ */

View File

@@ -33,6 +33,7 @@ const cli_cmd_spec cli_cmds[] = {
{ "blame", cmd_blame, "Show the origin of each line of a file" },
{ "cat-file", cmd_cat_file, "Display an object in the repository" },
{ "clone", cmd_clone, "Clone a repository into a new directory" },
{ "commit", cmd_commit, "Commit the current contents of the index" },
{ "config", cmd_config, "View or set configuration values " },
{ "hash-object", cmd_hash_object, "Hash a raw object and product its object ID" },
{ "help", cmd_help, "Display help information" },

View File

@@ -270,7 +270,9 @@ static cli_opt_status_t parse_short(cli_opt *opt, cli_opt_parser *parser)
}
/* Required argument was not provided */
if (spec->type == CLI_OPT_TYPE_VALUE && !opt->value)
if (spec->type == CLI_OPT_TYPE_VALUE &&
!opt->value &&
!(spec->usage & CLI_OPT_USAGE_VALUE_OPTIONAL))
opt->status = CLI_OPT_STATUS_MISSING_VALUE;
else
opt->status = CLI_OPT_STATUS_OK;

View File

@@ -14,7 +14,7 @@
static int print_spec_args(git_str *out, const cli_opt_spec *spec)
{
GIT_ASSERT(!is_switch_or_value(spec));
CLI_ASSERT(!is_switch_or_value(spec));
if (spec->type == CLI_OPT_TYPE_ARG)
return git_str_printf(out, "<%s>", spec->value_name);
@@ -23,13 +23,13 @@ static int print_spec_args(git_str *out, const cli_opt_spec *spec)
if (spec->type == CLI_OPT_TYPE_LITERAL)
return git_str_printf(out, "--");
GIT_ASSERT(!"unknown option spec type");
CLI_ASSERT(!"unknown option spec type");
return -1;
}
GIT_INLINE(int) print_spec_alias(git_str *out, const cli_opt_spec *spec)
{
GIT_ASSERT(is_switch_or_value(spec) && spec->alias);
CLI_ASSERT(is_switch_or_value(spec) && spec->alias);
if (spec->type == CLI_OPT_TYPE_VALUE &&
!(spec->usage & CLI_OPT_USAGE_VALUE_OPTIONAL))
@@ -42,7 +42,7 @@ GIT_INLINE(int) print_spec_alias(git_str *out, const cli_opt_spec *spec)
GIT_INLINE(int) print_spec_name(git_str *out, const cli_opt_spec *spec)
{
GIT_ASSERT(is_switch_or_value(spec) && spec->name);
CLI_ASSERT(is_switch_or_value(spec) && spec->name);
if (spec->type == CLI_OPT_TYPE_VALUE &&
!(spec->usage & CLI_OPT_USAGE_VALUE_OPTIONAL))

View File

@@ -302,7 +302,7 @@ int cli_progress_fetch_transfer(const git_indexer_progress *stats, void *payload
default:
/* should not be reached */
GIT_ASSERT(!"unexpected progress state");
CLI_ASSERT(!"unexpected progress state");
}
return error;
@@ -337,7 +337,7 @@ int cli_progress_indexer(
default:
/* should not be reached */
GIT_ASSERT(!"unexpected progress state");
CLI_ASSERT(!"unexpected progress state");
}
return error;

View File

@@ -204,7 +204,9 @@ extern int git_process_new_from_cmdline(
{
git_process_options merged_opts = {0};
memcpy(&merged_opts, opts, sizeof(git_process_options));
if (opts)
memcpy(&merged_opts, opts, sizeof(git_process_options));
merged_opts.use_shell = 1;
return git_process_new(out, &cmdline, 1, env, env_len, &merged_opts);