Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,11 @@ gitrepoforge apply [flags]
- Creates branch `{branch_prefix}update` (e.g. `gitrepoforge/update`).
- Applies file changes (`create`, `update`, `delete`).
- Stages all changes with `git add -A`.
- Commits with message `"gitrepoforge: apply desired state"`.
- Pushes to `origin`.
- If `create_pr` is enabled and the remote branch did not already exist, opens a PR via `gh pr create`.
- Checks out back to the default branch.
- Commits with the configured `git.commit_message` (default: `"gitrepoforge: apply desired state"`).
- If `git.push` is `true`, pushes to the configured `git.remote`.
- If `git.pull_request` is `GITHUB_CLI` and the remote branch did not already exist, opens a PR via `gh pr create`.
- If `git.return_to_original_branch` is `true`, checks out back to the default branch.
- If `git.delete_branch` is `true`, deletes the local feature branch.

### Statuses

Expand Down Expand Up @@ -96,7 +97,8 @@ gitrepoforge bootstrap --repo <name> [flags]
Same as `apply` with these differences:

- Branch name: `{branch_prefix}bootstrap` (e.g. `gitrepoforge/bootstrap`).
- Commit message: `"gitrepoforge: bootstrap repo"`.
- Commit message: configured via `git.bootstrap_commit_message` (default: `"gitrepoforge: bootstrap repo"`).
- PR title/body use `git.bootstrap_pr_title` and `git.bootstrap_pr_body`.

## Output

Expand Down
40 changes: 29 additions & 11 deletions docs/gitops.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Git Operations

gitrepoforge performs Git operations during `apply` and `bootstrap` commands. All operations run inside each target repository using the local `git` CLI and the GitHub CLI (`gh`).
gitrepoforge performs Git operations during `apply` and `bootstrap` commands. All operations run inside each target repository using the local `git` CLI and the GitHub CLI (`gh`). The behavior of each step is controlled by the `git` section in the root config (see [root-config.md](root-config.md)).

## Branching

Expand All @@ -11,7 +11,7 @@ Before making changes, gitrepoforge creates a dedicated branch:
| `apply` | `{branch_prefix}update` |
| `bootstrap` | `{branch_prefix}bootstrap` |

The `branch_prefix` is set in the root config (default: `gitrepoforge/`), so the default branch names are `gitrepoforge/update` and `gitrepoforge/bootstrap`.
The `branch_prefix` is set in the root config `git` section (default: `gitrepoforge/`), so the default branch names are `gitrepoforge/update` and `gitrepoforge/bootstrap`.

### Branch Creation Flow

Expand All @@ -24,46 +24,64 @@ After file changes are applied:

1. **Stage** all changes: `git add -A`
2. **Check** for staged changes: `git diff --cached --quiet`
3. If changes exist, **commit**:
- `apply`: `"gitrepoforge: apply desired state"`
- `bootstrap`: `"gitrepoforge: bootstrap repo"`
3. If changes exist, **commit** with the configured message:
- `apply` uses `git.commit_message` (default: `"gitrepoforge: apply desired state"`)
- `bootstrap` uses `git.bootstrap_commit_message` (default: `"gitrepoforge: bootstrap repo"`)

If there are no staged changes after applying rules, the repo is reported as `clean` and no commit is made.

## Push

Changes are pushed to the remote:
If `git.push` is `true` (the default), changes are pushed to the configured remote:

```
git push origin {branch}
git push {remote} {branch}
```

The `remote` defaults to `origin` and can be overridden in the root config `git` section.

If `git.push` is `false`, changes are committed locally but not pushed. Pull request creation is also skipped.

## Pull Request Creation

If `create_pr` is `true` in the root config **and** the remote branch did not already exist before pushing, gitrepoforge opens a pull request using the GitHub CLI:
If `git.pull_request` is `GITHUB_CLI` **and** the remote branch did not already exist before pushing, gitrepoforge opens a pull request using the GitHub CLI:

```
gh pr create --head {branch} --base {default_branch} --title {title} --body {body}
```

- The base branch is the `default_branch` from the repo's `.gitrepoforge` file.
- The title and body are configurable via `git.pr_title` / `git.pr_body` for `apply` and `git.bootstrap_pr_title` / `git.bootstrap_pr_body` for `bootstrap`.
- PRs are only created for new remote branches to avoid duplicate PRs on subsequent runs.
- If `git.pull_request` is `NO` (the default), no PR is created.

### Prerequisites

- The `gh` CLI must be installed and authenticated.
- The repo must have a GitHub remote named `origin`.
- The repo must have a GitHub remote matching the configured `git.remote`.

## Checkout Restore

After pushing (and optionally creating a PR), gitrepoforge checks out back to the `default_branch`:
If `git.return_to_original_branch` is `true` (the default), gitrepoforge checks out back to the `default_branch` after pushing:

```
git checkout {default_branch}
```

This leaves the working tree on the default branch regardless of success or failure.

If `git.return_to_original_branch` is `false`, gitrepoforge leaves the working tree on the feature branch.

## Branch Deletion

If `git.delete_branch` is `true` and `git.return_to_original_branch` is also `true`, gitrepoforge deletes the local feature branch after switching back to the default branch:

```
git branch -D {branch}
```

This mirrors the behavior of the repver tool's `delete_branch` option. The branch must have already been pushed to the remote (if `push` is enabled) before deletion.

## Status Checks

gitrepoforge uses these checks during operations:
Expand All @@ -78,4 +96,4 @@ gitrepoforge uses these checks during operations:

## Error Handling

If a Git operation fails, the repo is reported with status `failed` and the error is included in the output. gitrepoforge attempts to check out back to the default branch even after a failure.
If a Git operation fails, the repo is reported with status `failed` and the error is included in the output. gitrepoforge attempts to check out back to the default branch even after a failure (when `return_to_original_branch` is enabled).
63 changes: 59 additions & 4 deletions docs/root-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,19 @@ The root config lives at the workspace root, outside the managed repos, in `.git
config_repo: config-repo
excludes:
- archived-*
branch_prefix: gitrepoforge/
create_pr: false
git:
branch_prefix: gitrepoforge/
commit_message: "gitrepoforge: apply desired state"
bootstrap_commit_message: "gitrepoforge: bootstrap repo"
push: true
remote: origin
pull_request: GITHUB_CLI
pr_title: "gitrepoforge: apply desired state"
pr_body: "Automated changes applied by gitrepoforge."
bootstrap_pr_title: "gitrepoforge: bootstrap repo"
bootstrap_pr_body: "Automated bootstrap by gitrepoforge."
return_to_original_branch: true
delete_branch: false
```

## Fields
Expand All @@ -18,5 +29,49 @@ create_pr: false
|-------|----------|-------------|
| `config_repo` | yes | Relative or absolute path to the config repo. |
| `excludes` | no | Repo folder globs to skip during discovery. |
| `branch_prefix` | no | Prefix for branches created by `apply` and `bootstrap`. Defaults to `gitrepoforge/`. |
| `create_pr` | no | If true, open a pull request after a successful push. |
| `git` | no | Git automation options (see below). |

## Git Section

The `git` section controls how `apply` and `bootstrap` interact with Git. All fields are optional and have sensible defaults.

| Field | Default | Description |
|-------|---------|-------------|
| `branch_prefix` | `gitrepoforge/` | Prefix for branches created by `apply` and `bootstrap`. The suffix `update` or `bootstrap` is appended automatically. |
| `commit_message` | `gitrepoforge: apply desired state` | Commit message used by `apply`. |
| `bootstrap_commit_message` | `gitrepoforge: bootstrap repo` | Commit message used by `bootstrap`. |
| `push` | `true` | Push the branch to the remote after committing. |
| `remote` | `origin` | Git remote to push to. |
| `pull_request` | `NO` | Pull request creation method. `NO` disables PR creation. `GITHUB_CLI` creates a PR using the `gh` CLI. |
| `pr_title` | value of `commit_message` | Title for pull requests opened by `apply`. |
| `pr_body` | `Automated changes applied by gitrepoforge.` | Body for pull requests opened by `apply`. |
| `bootstrap_pr_title` | value of `bootstrap_commit_message` | Title for pull requests opened by `bootstrap`. |
| `bootstrap_pr_body` | `Automated bootstrap by gitrepoforge.` | Body for pull requests opened by `bootstrap`. |
| `return_to_original_branch` | `true` | Check out back to the default branch after pushing. |
| `delete_branch` | `false` | Delete the local branch after returning to the original branch. Requires `return_to_original_branch` to be `true`. |

### Validation Rules

- `pull_request` must be `NO` or `GITHUB_CLI` (case-insensitive).
- `pull_request` cannot be `GITHUB_CLI` when `push` is `false`.
- `delete_branch` requires `return_to_original_branch` to be `true`.

## Backward Compatibility

The legacy top-level fields `branch_prefix` and `create_pr` are still accepted for backward compatibility. If both a legacy field and the corresponding `git` section field are present, the `git` section takes precedence.

| Legacy field | Equivalent `git` field |
|--------------|----------------------|
| `branch_prefix` | `git.branch_prefix` |
| `create_pr: true` | `git.pull_request: GITHUB_CLI` |
| `create_pr: false` | `git.pull_request: NO` |

### Legacy example

```yaml
config_repo: config-repo
excludes:
- archived-*
branch_prefix: gitrepoforge/
create_pr: false
```
55 changes: 33 additions & 22 deletions internal/cmd/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ func applyRepo(repoPath, repoName string, rootCfg *config.RootConfig, centralCfg
}

// Create branch, apply changes, commit, push
branchName := rootCfg.BranchPrefix + "update"
branchName := rootCfg.Git.BranchPrefix + "update"

// Checkout default branch first
if err := gitops.CheckoutBranch(repoPath, repoCfg.DefaultBranch); err != nil {
Expand Down Expand Up @@ -212,7 +212,7 @@ func applyRepo(repoPath, repoName string, rootCfg *config.RootConfig, centralCfg
}
}

if err := gitops.Commit(repoPath, "gitrepoforge: apply desired state"); err != nil {
if err := gitops.Commit(repoPath, rootCfg.Git.CommitMessage); err != nil {
gitops.CheckoutBranch(repoPath, repoCfg.DefaultBranch)
return output.RepoResult{
Name: repoName,
Expand All @@ -221,32 +221,43 @@ func applyRepo(repoPath, repoName string, rootCfg *config.RootConfig, centralCfg
}
}

// Push
if err := gitops.Push(repoPath, branchName); err != nil {
gitops.CheckoutBranch(repoPath, repoCfg.DefaultBranch)
return output.RepoResult{
Name: repoName,
Status: "failed",
ValidationErrors: []string{fmt.Sprintf("failed to push: %v", err)},
// Push if configured
if *rootCfg.Git.Push {
if err := gitops.Push(repoPath, rootCfg.Git.Remote, branchName); err != nil {
gitops.CheckoutBranch(repoPath, repoCfg.DefaultBranch)
return output.RepoResult{
Name: repoName,
Status: "failed",
ValidationErrors: []string{fmt.Sprintf("failed to push: %v", err)},
}
}
}

// Create PR if configured and remote branch didn't already exist
if rootCfg.CreatePR {
if remoteBranchExists {
output.Warning(fmt.Sprintf("%s: remote branch %s already exists; skipping PR creation", repoName, branchName))
} else {
err := gitops.CreatePR(repoPath, branchName, repoCfg.DefaultBranch,
"gitrepoforge: apply desired state",
"Automated changes applied by gitrepoforge.")
if err != nil {
output.Warning(fmt.Sprintf("%s: PR creation failed: %v", repoName, err))
// Create PR if configured and remote branch didn't already exist
if rootCfg.Git.PullRequest == config.PullRequestGitHubCLI {
if remoteBranchExists {
output.Warning(fmt.Sprintf("%s: remote branch %s already exists; skipping PR creation", repoName, branchName))
} else {
err := gitops.CreatePR(repoPath, branchName, repoCfg.DefaultBranch,
rootCfg.Git.PRTitle,
rootCfg.Git.PRBody)
if err != nil {
output.Warning(fmt.Sprintf("%s: PR creation failed: %v", repoName, err))
}
}
}
}

// Return to default branch
gitops.CheckoutBranch(repoPath, repoCfg.DefaultBranch)
// Return to original branch if configured
if *rootCfg.Git.ReturnToOriginalBranch {
gitops.CheckoutBranch(repoPath, repoCfg.DefaultBranch)

// Delete the branch if configured
if rootCfg.Git.DeleteBranch {
if err := gitops.DeleteBranch(repoPath, branchName); err != nil {
output.Warning(fmt.Sprintf("%s: failed to delete branch %s: %v", repoName, branchName, err))
}
}
}

var findingOutputs []output.FindingOutput
for _, f := range findings {
Expand Down
49 changes: 30 additions & 19 deletions internal/cmd/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ func bootstrapRepo(repoPath, repoName string, rootCfg *config.RootConfig, centra
}

// Bootstrap uses a distinct branch name
branchName := rootCfg.BranchPrefix + "bootstrap"
branchName := rootCfg.Git.BranchPrefix + "bootstrap"

// Checkout default branch first
if err := gitops.CheckoutBranch(repoPath, repoCfg.DefaultBranch); err != nil {
Expand Down Expand Up @@ -188,7 +188,7 @@ func bootstrapRepo(repoPath, repoName string, rootCfg *config.RootConfig, centra
}
}

if err := gitops.Commit(repoPath, "gitrepoforge: bootstrap repo"); err != nil {
if err := gitops.Commit(repoPath, rootCfg.Git.BootstrapCommitMessage); err != nil {
gitops.CheckoutBranch(repoPath, repoCfg.DefaultBranch)
return output.RepoResult{
Name: repoName,
Expand All @@ -197,28 +197,39 @@ func bootstrapRepo(repoPath, repoName string, rootCfg *config.RootConfig, centra
}
}

// Push
if err := gitops.Push(repoPath, branchName); err != nil {
gitops.CheckoutBranch(repoPath, repoCfg.DefaultBranch)
return output.RepoResult{
Name: repoName,
Status: "failed",
ValidationErrors: []string{fmt.Sprintf("failed to push: %v", err)},
// Push if configured
if *rootCfg.Git.Push {
if err := gitops.Push(repoPath, rootCfg.Git.Remote, branchName); err != nil {
gitops.CheckoutBranch(repoPath, repoCfg.DefaultBranch)
return output.RepoResult{
Name: repoName,
Status: "failed",
ValidationErrors: []string{fmt.Sprintf("failed to push: %v", err)},
}
}
}

// Create PR if configured
if rootCfg.CreatePR {
err := gitops.CreatePR(repoPath, branchName, repoCfg.DefaultBranch,
"gitrepoforge: bootstrap repo",
"Automated bootstrap by gitrepoforge.")
if err != nil {
output.Warning(fmt.Sprintf("%s: PR creation failed: %v", repoName, err))
// Create PR if configured
if rootCfg.Git.PullRequest == config.PullRequestGitHubCLI {
err := gitops.CreatePR(repoPath, branchName, repoCfg.DefaultBranch,
rootCfg.Git.BootstrapPRTitle,
rootCfg.Git.BootstrapPRBody)
if err != nil {
output.Warning(fmt.Sprintf("%s: PR creation failed: %v", repoName, err))
}
}
}

// Return to default branch
gitops.CheckoutBranch(repoPath, repoCfg.DefaultBranch)
// Return to original branch if configured
if *rootCfg.Git.ReturnToOriginalBranch {
gitops.CheckoutBranch(repoPath, repoCfg.DefaultBranch)

// Delete the branch if configured
if rootCfg.Git.DeleteBranch {
if err := gitops.DeleteBranch(repoPath, branchName); err != nil {
output.Warning(fmt.Sprintf("%s: failed to delete branch %s: %v", repoName, branchName, err))
}
}
}

var findingOutputs []output.FindingOutput
for _, f := range findings {
Expand Down
Loading
Loading