From 39d23b0ec5504734ddae16e37be0c0168e3bee6e Mon Sep 17 00:00:00 2001 From: Alex Vidal Date: Thu, 10 Jul 2025 14:48:36 -0500 Subject: [PATCH] feat: commit subcommand This patch introduces the commit subcommand, suitable for simple use cases where a single commit is all that's necessary, and the set of files that were changed is known in advance. See the updates to the README and the help output for more. --- README.md | 57 ++++++++++++++++++++++++++------- cmd_commit.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 1 + version.go | 2 +- 4 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 cmd_commit.go diff --git a/README.md b/README.md index 4173dbd..0a0bf9b 100644 --- a/README.md +++ b/README.md @@ -13,28 +13,60 @@ GitHub on behalf of the application. ## Usage -Currently, there is one command: `commit-headless push`. It takes a target owner/repository and -remote branch name, as well as a list of commit hashes as arguments *or* a list of commit hashes *in -reverse chronological order (newest first)* on standard input. +There are two ways to create signed headless commits with this tool: `push` and `commit`. + +Both of these commands take a target owner/repository (eg, `--target/-T DataDog/commit-headless`) +and remote branch name (eg, `--branch bot-branch`) as required flags and expect to find a GitHub +token in one of the following environment variables: + +- HEADLESS_TOKEN +- GITHUB_TOKEN +- GH_TOKEN + +In normal usage, `commit-headless` will print *only* the reference to the last commit created on the +remote, allowing this to easily be captured in a script. + +More on the specifics for each command below. See also: `commit-headless --help` + +### commit-headless push + +In addition to the required target and branch flags, the `push` command expects a list of commit +hashes as arguments *or* a list of commit hashes *in reverse chronological order (newest first)* +on standard input. It will iterate over the supplied commits, extract the set of changed files and commit message, then craft new *remote* commits corresponding to each local commit. The remote commit will have the original commit message, with "Co-authored-by" trailer for the -original commit message. This is because commits created using the GraphQL API do not support -setting the author or committer (they are inferred from the token owner), so adding a -"Co-authored-by" trailer allows the commits to carry attribution to the original (bot) committer. - -In normal usage, `commit-headless` will print *only* the reference to the last commit created on the -remote, allowing this to easily be captured in a script. For example output, see the later section. +original commit author. You can use `commit-headless push` via: - GH_TOKEN=xyz commit-headless push --target datadog/commit-headless --branch bot-branch-remote HASH1 HASH2 HASH3 ... + commit-headless push [flags...] HASH1 HASH2 HASH3 ... Or, using git log (note `--oneline`): - git log --oneline main.. | GH_TOKEN=xyz commit-headless push --target datadog/commit-headless --branch bot-branch-remote + git log --oneline main.. | commit-headless push [flags...] + +### commit-headless commit + +This command is more geared for creating single commits at a time. It takes a list of files to +commit changes to, and those files will either be updated/added or deleted in a single commit. + +Note that you cannot delete a file without also adding `--force` for safety reasons. + +Examples: + + # Commit changes to these two files + commit-headless commit [flags...] -- README.md .gitlab-ci.yml + + # Remove a file, add another one, and commit + rm file/i/do/not/want + echo "hello" > hi-there.txt + commit-headless commit [flags...] --force -- hi-there.txt file/i/do/not/want + + # Commit a change with a custom message + commit-headless commit [flags...] -m"ran a pipeline" -- output.txt ## Try it! @@ -88,13 +120,14 @@ Prerelease occurs automatically on a push to main, or can be manually triggered `release:build` job on any branch. Additionally, on main, the `release:publish` job will run. This job takes the prerelease image and -tags it for release. +tags it for release, as well as produces a CI image with various other tools. You can view all releases (and prereleases) with crane: ``` $ crane ls registry.ddbuild.io/commit-headless-prerelease $ crane ls registry.ddbuild.io/commit-headless +$ crane ls registry.ddbuild.io/commit-headless-ci-image ``` Note that the final publish job will fail unless there was also a change to `version.go` to avoid diff --git a/cmd_commit.go b/cmd_commit.go new file mode 100644 index 0000000..562e58e --- /dev/null +++ b/cmd_commit.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "strings" +) + +type CommitCmd struct { + remoteFlags + + Author string `help:"Specify an author using the standard 'A U Thor ' format."` + Message []string `short:"m" help:"Specify a commit message. If used multiple times, values are concatenated as separate paragraphs."` + Force bool `help:"Force commiting empty files. Only useful if you know you're deleting a file."` + Files []string `arg:"" help:"Files to commit."` +} + +func (c *CommitCmd) Help() string { + return ` +This command can be used to create a single commit on the remote by passing in the names of files. + +It is expected that the paths on disk match to paths on the remote. That is, if you supply +"path/to/file.txt" then the contents of that file on disk will be applied to that same file on the +remote when the commit is created. + +You can also use this to delete files by passing a path to a file that does not exist on disk. Note +that for safety reasons, commit-headless will require an extra flag --force before accepting +deletions. It is an error to attempt to delete a file that does not exist. + +If you pass a path to a file that does not exist on disk without the --force flag, commit-headless +will print an error and exit. + +You can supply a commit message via --message/-m and an author via --author/-a. If unspecified, +default values will be used. + +Examples: + # Commit changes to these two files + commit-headless commit [flags...] -- README.md .gitlab-ci.yml + + # Remove a file, add another one, and commit + rm file/i/do/not/want + echo "hello" > hi-there.txt + commit-headless commit [flags...] --force -- hi-there.txt file/i/do/not/want + + # Commit a change with a custom message + commit-headless commit [flags...] -m"ran a pipeline" -- output.txt + ` +} + +func (c *CommitCmd) Run() error { + change := Change{ + hash: strings.Repeat("0", 40), + author: c.Author, + message: strings.Join(c.Message, "\n\n"), + entries: map[string][]byte{}, + } + + rootfs := os.DirFS(".") + + for _, path := range c.Files { + fp, err := rootfs.Open(path) + if errors.Is(err, fs.ErrNotExist) { + if !c.Force { + return fmt.Errorf("file %q does not exist, but --force was not set", path) + } + + change.entries[path] = []byte{} + continue + } else if err != nil { + return fmt.Errorf("could not open file %q: %w", path, err) + } + + contents, err := io.ReadAll(fp) + if err != nil { + return fmt.Errorf("read %q: %w", path, err) + } + + change.entries[path] = contents + } + + owner, repository := c.Target.Owner(), c.Target.Repository() + + return pushChanges(context.Background(), owner, repository, c.Branch, c.DryRun, change) +} diff --git a/main.go b/main.go index ce01006..8603a0e 100644 --- a/main.go +++ b/main.go @@ -49,6 +49,7 @@ type remoteFlags struct { type CLI struct { Push PushCmd `cmd:"" help:"Push local commits to the remote."` + Commit CommitCmd `cmd:"" help:"Create a commit directly on the remote."` Version VersionCmd `cmd:"" help:"Print version information and exit."` } diff --git a/version.go b/version.go index 9077aa4..7ba826e 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package main -const VERSION = "0.2.0" +const VERSION = "0.3.0"