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
103 changes: 103 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# AGENTS.md

## Project Overview

**ginx** is a lightweight Go CLI tool that monitors remote Git repositories for changes and executes custom commands when updates are detected. Built for automating deployments, tasks, and workflows.

- **Module:** `github.com/didactiklabs/ginx`
- **Go version:** 1.23.3
- **License:** MIT
- **Current version:** v0.0.7

## Tech Stack

| Component | Technology |
|---|---|
| Language | Go 1.23.3 |
| CLI framework | `github.com/spf13/cobra` v1.8.1 |
| Git operations | `github.com/go-git/go-git/v5` v5.13.2 |
| Logging | `go.uber.org/zap` v1.27.0 |
| Release/Build | GoReleaser v2 |
| CI/CD | GitHub Actions |
| Linting | gofumpt + golines (max 140 chars) |

## Directory Structure

```
.
├── main.go # Entry point
├── go.mod / go.sum # Go module files
├── cmd/
│ └── root.go # Cobra CLI command definitions
├── internal/
│ └── utils/
│ ├── git.go # Git clone, pull, commit checking
│ └── zap.go # Logger initialization
├── scripts/
│ └── gen_doc.go # CLI doc generator
├── docs/
│ └── ginx.md # Auto-generated CLI docs
├── public/
│ └── ginx.png # Project logo
└── .github/
├── workflows/
│ ├── linting.yaml # Lint on PRs
│ ├── unittest.yaml # Tests on push/PR to main
│ ├── build.yaml # GoReleaser dry run on PRs
│ └── release.yaml # GoReleaser release on tag push
├── dependabot.yml
└── release.yml
```

## Commands

```bash
# Build
go build -o ginx ./

# Run
go run ./ --source <repo-url> -b <branch> -n <interval> -- <command>

# Test
go test -coverprofile=coverage.out ./...

# Lint (must pass CI)
gofumpt -d .
golines --max-len=140 . --dry-run

# Generate CLI docs
go run ./scripts/gen_doc.go

# GoReleaser dry run
goreleaser release --snapshot
```

## Code Conventions

- **Formatting:** `gofumpt` (stricter than gofmt), max line length 140 via `golines`
- **Package layout:** `main.go` at root, cobra commands in `cmd/`, shared utilities in `internal/utils/`
- **Naming:** Flag vars use camelCase with `Flag` suffix (e.g., `sourceFlag`). Exported types/funcs use PascalCase.
- **Error handling:** Fatal errors use `utils.Logger.Fatal(...)`. Non-fatal use `utils.Logger.Error(...)`. Clean up temp dirs with `os.RemoveAll(dir)` on error paths.
- **Logging:** Structured JSON via zap. Levels: debug, info, error. Set via `--log-level` flag.
- **No comments** in code unless explicitly requested.
- **No emojis** in code or docs unless explicitly requested.

## Branch & Commit Conventions

- **Branches:** `feat/<description>`, `fix/<description>`, `chore/<description>`
- **Commits:** Emoji-prefixed format, e.g., `feat : description`, `fix : description`, `chore : description`

## Architecture Notes

1. **Flow:** Create temp dir → clone remote repo → poll at interval → compare remote/local commits → pull + run command on change.
2. **`internal/utils/git.go`:** Pure Go git via `go-git`. Functions: `IsRepoCloned`, `CloneRepo`, `PullRepo`, `RunCommand`, `GetLatestRemoteCommit`, `GetLatestLocalCommit`.
3. **`internal/utils/zap.go`:** Exports a global `Logger` variable initialized in `cmd/root.go`'s `PersistentPreRun`.
4. **Version injection:** `cmd.version` set via ldflags at build time by GoReleaser.

## Known Gaps

- No test files (`*_test.go`) exist despite CI running `go test`.
- No `.gitignore` file.
- No Makefile or Dockerfile.
- `IsRepoCloned` only checks directory existence, not git validity.
- Temp dir cleanup lacks defer-based safety for unexpected process termination.
229 changes: 112 additions & 117 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package cmd

import (
"context"
"fmt"
"os"
"os/signal"
"path"
"strings"
"syscall"
"time"

"github.com/go-git/go-git/v5"
Expand All @@ -29,145 +32,137 @@ var (
var RootCmd = &cobra.Command{
Use: "ginx [flags] -- <command>",
Short: "ginx",
Long: `
Ginx is a cli tool that watch a remote repository and run an arbitrary command on changes/updates.
`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Initialize configuration here
Long: `Ginx is a cli tool that watch a remote repository and run an arbitrary command on changes/updates.`,
PersistentPreRun: func(_ *cobra.Command, _ []string) {
initConfig()
},
Run: func(cmd *cobra.Command, args []string) {
if versionFlag {
fmt.Printf("%s", version)
os.Exit(0)
Run: run,
Args: cobra.ArbitraryArgs,
}

func run(_ *cobra.Command, args []string) {
defer utils.SyncLogger()

if versionFlag {
fmt.Printf("%s", version)
return
}

source := sourceFlag
branch := branchFlag
interval := time.Duration(pollIntervalFlag) * time.Second
projectName := path.Base(strings.TrimSuffix(source, "/"))

dir, err := os.MkdirTemp("", fmt.Sprintf("ginx-%s-*", projectName))
if err != nil {
utils.Logger.Fatal("Failed to create temporary directory.", zap.Error(err))
}
defer os.RemoveAll(dir)

r, err := initRepo(source, branch, dir)
if err != nil {
utils.Logger.Fatal("Failed to initialize repository.", zap.Error(err))
}

if nowFlag {
runOnce(args, dir)
return
}

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

poll(ctx, r, source, branch, dir, args, interval)
}

func initRepo(source, branch, dir string) (*git.Repository, error) {
if !utils.IsRepoCloned(source) {
utils.Logger.Info("Cloning repository.", zap.String("url", source), zap.String("branch", branch))
return utils.CloneRepo(source, branch, dir)
}
utils.Logger.Info("Repository already exists, opening directory.", zap.String("directory", dir))
return git.PlainOpen(dir)
}

func runOnce(args []string, dir string) {
if len(args) == 0 {
return
}
utils.Logger.Info("Running command.", zap.String("command", args[0]), zap.Any("args", args[1:]))
if err := utils.RunCommand(dir, args[0], args[1:]...); err != nil {
utils.Logger.Error("Failed to run command.", zap.Error(err))
}
}

func poll(
ctx context.Context,
r *git.Repository,
source, branch, dir string,
args []string,
interval time.Duration,
) {
for {
select {
case <-ctx.Done():
utils.Logger.Info("Shutdown signal received, exiting.")
return
default:
}

var r *git.Repository
var err error
source := sourceFlag
branch := branchFlag
interval := time.Duration(pollIntervalFlag) * time.Second
projectName := path.Base(strings.TrimSuffix(source, "/"))
dir, err := os.MkdirTemp("", fmt.Sprintf("ginx-%s-*", projectName))
remoteCommit, err := utils.GetLatestRemoteCommit(r, branch)
if err != nil {
utils.Logger.Fatal("Failed to create temporary directory.", zap.Error(err))
utils.Logger.Fatal("Error fetching remote commit.", zap.Error(err))
}
utils.Logger.Debug("Fetched remote commit.", zap.String("remoteCommit", remoteCommit))

if !utils.IsRepoCloned(source) {
utils.Logger.Info("Cloning repository.", zap.String("url", source), zap.String("branch", branch))
r, err = utils.CloneRepo(source, branch, dir)
if err != nil {
err := os.RemoveAll(dir)
if err != nil {
utils.Logger.Fatal("error removing directory.", zap.Error(err))
}
utils.Logger.Fatal("Failed to clone repository.", zap.Error(err))
}
} else {
r, err = git.PlainOpen(dir)
utils.Logger.Info("Repository already exist, open directory repository.", zap.String("directory", dir))
if err != nil {
err := os.RemoveAll(dir)
if err != nil {
utils.Logger.Fatal("error removing directory.", zap.Error(err))
}
utils.Logger.Fatal("Failed to open existing directory repository.", zap.Error(err))
}
localCommit, err := utils.GetLatestLocalCommit(dir)
if err != nil {
utils.Logger.Fatal("Error fetching local commit.", zap.Error(err))
}
if nowFlag {
if len(args) > 0 {
utils.Logger.Info("Running command.", zap.String("command", args[0]), zap.Any("args", args[1:]))
if err := utils.RunCommand(dir, args[0], args[1:]...); err != nil {
err := os.RemoveAll(dir)
if err != nil {
utils.Logger.Fatal("error removing directory.", zap.Error(err))
}
utils.Logger.Error("Failed to run command.", zap.Error(err))
}
}
err := os.RemoveAll(dir)
if err != nil {
utils.Logger.Fatal("error removing directory.", zap.Error(err))
}
os.Exit(0)
utils.Logger.Debug("Fetched local commit.", zap.String("localCommit", localCommit))

if remoteCommit == localCommit {
utils.Logger.Info(
"No changes detected.",
zap.String("url", source),
zap.String("branch", branch),
)
time.Sleep(interval)
continue
}

for {
// Get the latest commit hash from the remote repository
remoteCommit, err := utils.GetLatestRemoteCommit(r, branch)
utils.Logger.Debug("Fetched remote commit.", zap.String("remoteCommit", remoteCommit))
if err != nil {
err := os.RemoveAll(dir)
if err != nil {
utils.Logger.Fatal("error removing directory.", zap.Error(err))
}
utils.Logger.Fatal("error fetching local commit.", zap.Error(err))
}
utils.Logger.Info("Detected remote changes.", zap.String("url", source), zap.String("branch", branch))

// Get the latest commit hash from the local repository
localCommit, err := utils.GetLatestLocalCommit(dir)
utils.Logger.Debug("Fetched local commit.", zap.String("localCommit", localCommit))
if err != nil {
err := os.RemoveAll(dir)
if err != nil {
utils.Logger.Fatal("error removing directory.", zap.Error(err))
}
utils.Logger.Fatal("error fetching local commit.", zap.Error(err))
if err := utils.PullRepo(r); err != nil {
utils.Logger.Info("Failed to pull, recloning.", zap.String("url", source))
newR, cloneErr := utils.CloneRepo(source, branch, dir)
if cloneErr != nil {
utils.Logger.Fatal("Failed to reclone repository.", zap.Error(cloneErr))
}
r = newR
}

if remoteCommit != localCommit {
utils.Logger.Info("Detected remote changes.", zap.String("url", source), zap.String("branch", branch))
if err := utils.PullRepo(r); err != nil {
utils.Logger.Info("Failed to pull. Recloning repository.", zap.String("url", source))
err := os.RemoveAll(dir)
if err != nil {
utils.Logger.Fatal("error removing directory.", zap.Error(err))
}
_, err = utils.CloneRepo(source, branch, dir)
if err != nil {
utils.Logger.Fatal("Failed to clone repository.", zap.Error(err))
err := os.RemoveAll(dir)
if err != nil {
utils.Logger.Fatal("error removing directory.", zap.Error(err))
}
}
}
if len(args) > 0 {
utils.Logger.Info("Running command.", zap.String("command", args[0]), zap.Any("args", args[1:]))
if err := utils.RunCommand(dir, args[0], args[1:]...); err != nil {
if exitFailFlag {
err := os.RemoveAll(dir)
if err != nil {
utils.Logger.Fatal("error removing directory.", zap.Error(err))
}
utils.Logger.Fatal("Failed to run command.", zap.Error(err))
}
err := os.RemoveAll(dir)
if err != nil {
utils.Logger.Fatal("error removing directory.", zap.Error(err))
}
utils.Logger.Error("Failed to run command.", zap.Error(err))
}
if len(args) > 0 {
utils.Logger.Info("Running command.", zap.String("command", args[0]), zap.Any("args", args[1:]))
if err := utils.RunCommand(dir, args[0], args[1:]...); err != nil {
if exitFailFlag {
utils.Logger.Fatal("Failed to run command.", zap.Error(err))
}
} else {
utils.Logger.Info("No changes detected in remote repository.", zap.String("url", source), zap.String("branch", branch))
utils.Logger.Error("Failed to run command.", zap.Error(err))
}
time.Sleep(interval)
}
},
Args: cobra.ArbitraryArgs,

time.Sleep(interval)
}
}

func initConfig() {
// Your configuration initialization logic
logLevel := zapcore.InfoLevel //nolint:all
logLevel := zapcore.InfoLevel
switch logLevelFlag {
case "debug":
logLevel = zapcore.DebugLevel
case "error":
logLevel = zapcore.ErrorLevel
default:
logLevel = zapcore.InfoLevel
}
utils.InitializeLogger(logLevel)
}
Expand All @@ -181,8 +176,8 @@ func Execute() {

func init() {
RootCmd.Flags().BoolVarP(&versionFlag, "version", "v", false, "display version information")
RootCmd.Flags().BoolVarP(&nowFlag, "now", "", false, "run the command on the targeted branch now")
RootCmd.Flags().BoolVarP(&exitFailFlag, "exit-on-fail", "", false, "exit on command fail")
RootCmd.Flags().BoolVar(&nowFlag, "now", false, "run the command on the targeted branch now")
RootCmd.Flags().BoolVar(&exitFailFlag, "exit-on-fail", false, "exit on command fail")
RootCmd.PersistentFlags().StringVarP(&logLevelFlag, "log-level", "l", "info", "override log level (debug, info, error)")
RootCmd.PersistentFlags().StringVarP(&sourceFlag, "source", "s", "", "git repository to watch")
RootCmd.PersistentFlags().StringVarP(&branchFlag, "branch", "b", "main", "branch to watch")
Expand Down
Loading
Loading