Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b42a5a4
fix(gsync): make standard checkout resilient on first sync
Rishikesh01 Sep 1, 2026
990a6b0
fix(gsync): harden openvox first-clone and object-missing checkouts
Rishikesh01 Sep 1, 2026
51a6422
chore: apply go fix (errors.As -> errors.AsType)
Rishikesh01 Sep 1, 2026
9f831e7
build: update to Go 1.27
Rishikesh01 Sep 1, 2026
b7da5b9
chore(gsync): resolve golangci-lint findings in checkout changes
Rishikesh01 Sep 1, 2026
8217e21
ci: bump golangci-lint to v2.13.2 for Go 1.27
Rishikesh01 Sep 1, 2026
f91f671
feat(config): add default_branch_only to sync just the remote HEAD br…
Rishikesh01 Sep 2, 2026
f2bea18
feat(config): reject settings default_branch_only would override
Rishikesh01 Sep 2, 2026
fa4ec8b
fix(gsync): stop default_branch_only pruning the branch it just synced
Rishikesh01 Sep 2, 2026
72b43be
fix(gsync): fetch Azure DevOps with git, and repair a corrupt index
Rishikesh01 Sep 3, 2026
98f3c48
fix(gsync): route tag and staleness fetches through git too
Rishikesh01 Sep 3, 2026
121f836
fix(gsync): detect an existing repo by .git, not by the directory
Rishikesh01 Sep 4, 2026
c4f0366
fix(gsync): advertise multi_ack so Azure DevOps sends a pack
Rishikesh01 Sep 4, 2026
08122d1
feat(config): default_branch_only excludes tags as well
Rishikesh01 Sep 4, 2026
9f47577
refactor(gsync): set capabilities in New, and note the v6 constraint
Rishikesh01 Sep 4, 2026
9f8bc4d
refactor(gsync): inline the single-caller known_hosts helper
Rishikesh01 Sep 4, 2026
d6858e3
refactor(gsync): simplify SyncRepo and the default-branch checkout
Rishikesh01 Sep 4, 2026
7d37805
fix(gsync): run the corrupt-index repair on every checkout path
Rishikesh01 Sep 4, 2026
d1b9ce0
fix(gsync): match go-git errors by sentinel and drop the object probe
Rishikesh01 Sep 4, 2026
6ad7cf3
test(gsync): reuse the existing repo setup helper
Rishikesh01 Sep 4, 2026
bd994cb
docs: document default_branch_only
Rishikesh01 Sep 4, 2026
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
2 changes: 1 addition & 1 deletion .gitea/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
cache: false
- uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
version: v2.13.2

fmt:
runs-on: ubuntu-24.04-htzhel1-ax42-a
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
cache: false
- uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
version: v2.13.2

fmt:
runs-on: ubuntu-latest
Expand Down
14 changes: 13 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ For AI coding agents: after making code changes, run `go fix ./...` before tests

## Code Conventions

- **Go version**: 1.25 (see `go.mod`)
- **Go version**: 1.27 (see `go.mod`)
- **Module path**: `github.com/obmondo/gfetch`
- **Logging**: `log/slog` with text handler to stderr. Logger is passed through structs (e.g., `Syncer.logger`), not globals. Use `.With("key", value)` for structured fields.
- **CLI framework**: `github.com/spf13/cobra`. Commands are defined in `internal/cli/` with `newXxxCmd()` factory functions. Flags use package-level vars.
Expand Down Expand Up @@ -201,3 +201,15 @@ Patterns appear in `branches` and `tags` YAML lists:

- **SSH**: any non-HTTPS URL. Uses `go-git/go-git/v5/plumbing/transport/ssh.NewPublicKeysFromFile("git", keyPath, "")`. No passphrase support.
- **HTTPS**: URLs starting with `https://` or `http://`. Auth is `nil` (anonymous). Only public repos are supported.

## Known future work

- **go-git v6 needs a rewrite, not a bump.** `Syncer.New` sets
`transport.UnsupportedCapabilities` to re-enable the multi_ack capabilities.
Without them Azure DevOps replies to a fetch with an empty pack and no error:
the refs get written, no objects arrive, and checkout then fails with "object
not found" forever, since the refs already point at the right commits. v6
reworks the transport layer and removes that global, so the assignment will
not compile and the fetch, clone and capability plumbing all need reworking.
Re-verify the Azure DevOps behaviour against whatever replaces it before
upgrading. v6 was alpha as of Sep 2026.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Stage 1 — builder
FROM golang:1.26.6-alpine AS builder
FROM golang:1.27.0-alpine AS builder

ARG VERSION=dev
ARG COMMIT=unknown
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A CLI tool that selectively mirrors remote Git repositories to local paths based
## Features

- **Selective sync** — choose exactly which branches and tags to mirror using exact names, wildcards (`*`), or regex patterns
- **Default-branch-only sync** — `default_branch_only: true` mirrors just the branch the remote's `HEAD` points at, for callers that don't know its name up front
- **Pruning** — detect and remove local branches/tags that no longer match any configured pattern
- **Stale pruning** — optionally remove inactive branches that have no new commits in a specified period (e.g., last 6 months); `prune_stale` only takes effect when `prune` is also enabled — stale branches are skipped before branch sync when both are set
- **Daemon mode** — run as a foreground polling service with per-repo poll intervals
Expand Down Expand Up @@ -107,6 +108,7 @@ defaults:
prune: true # remove branches/tags no longer matching any pattern
prune_stale: true # remove branches with no commits in 6 months (requires prune: true)
stale_age: 180d # supports d (days)
default_branch_only: false # sync only the branch the remote HEAD points at
production_alias: false # OpenVox-only: if true and upstream has no production branch,
# create/update production -> <default-branch-dir> symlink

Expand All @@ -128,12 +130,18 @@ repos:
prune_stale: false # override default for this repo
branches:
- main

customer-repo:
url: git@github.com:org/customer-repo.git
default_branch_only: true # whatever HEAD points at; no branches/tags needed
```

See [docs/configuration.md](docs/configuration.md) for the full configuration reference, including all fields, pattern syntax, auth methods, and validation rules.

**Important (OpenVox environments):** set `production_alias: true` to keep a stable `production` symlink pointing to the upstream default branch directory. gfetch skips alias creation if upstream already has a `production` branch.

**`default_branch_only`** replaces `branches`/`tags` rather than supplementing them: it syncs the remote's default branch and nothing else, and is rejected alongside `branches`, `tags`, `checkout` or `openvox` so a contradictory config fails instead of silently ignoring what you wrote. The branch is re-resolved from the remote on every sync, so a change of upstream default branch is followed automatically. See [Default Branch Only](docs/configuration.md#default-branch-only).

## Usage

### Global Flags
Expand Down
1 change: 1 addition & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defaults:
- main
tags:
- /^v[0-9]+\./
default_branch_only: false # standard mode only: sync just the branch HEAD points at, for callers that don't know its name up front. Syncs that branch and nothing else: rejected alongside branches, tags, checkout or openvox
openvox: false
openvox_max_workers: 5 # OpenVox-only worker concurrency per repo (1-64)
production_alias: false # when openvox=true and no upstream production branch exists, create production -> <default-branch-dir> symlink
Expand Down
53 changes: 50 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ Validation, however, tolerates per-repo failures: a repo that fails `Validate` (
| `ssh_known_hosts` | string | No | Extra SSH host key entries. Merged with built-in keys for GitHub, GitLab, Bitbucket, and Azure DevOps. |
| `local_path` | string | Yes | Local directory where the repo will be cloned and synced. |
| `poll_interval` | duration | Yes | How often the daemon polls this repo. Supports `30s`, `5m`, `1h`, `30d`. Minimum: `10s`. |
| `branches` | list of patterns | At least one of `branches` or `tags` | Branch names or patterns to sync from the remote. |
| `tags` | list of patterns | At least one of `branches` or `tags` | Tag names or patterns to sync from the remote. |
| `branches` | list of patterns | At least one of `branches` or `tags`, unless `default_branch_only` is set | Branch names or patterns to sync from the remote. |
| `tags` | list of patterns | At least one of `branches` or `tags`, unless `default_branch_only` is set | Tag names or patterns to sync from the remote. |
| `default_branch_only` | bool | No | Sync only the branch the remote's `HEAD` points at, whatever it is called. Cannot be combined with `branches`, `tags`, `checkout` or `openvox`. Default `false`. |
| `checkout` | string | No | A literal branch or tag name to check out in the working tree. |
| `openvox` | bool | No | Enable OpenVox mode. Each matching branch/tag gets its own subdirectory. |
| `openvox_max_workers` | int | No | OpenVox-only worker concurrency per repo. Default `5`, valid range `1..64`. |
Expand All @@ -70,6 +71,51 @@ Validation, however, tolerates per-repo failures: a repo that fails `Validate` (
| `prune_stale` | bool | No | If true, local branches matching patterns but with no commits in `stale_age` will be pruned during sync (requires `prune: true`). When both are enabled, stale branches are also skipped before branch sync. Default `false`. |
| `stale_age` | duration | No | The period of inactivity (based on committer date) after which a branch is considered stale. Default `180d`. |

## Default Branch Only

Set `default_branch_only: true` to mirror just the branch the remote's `HEAD`
points at, without naming it:

```yaml
repos:
customer-repo:
url: git@github.com:org/repo.git
local_path: /var/repos/customer-repo
poll_interval: 5m
default_branch_only: true
```

This is for callers that do not know the branch name up front — it may be
`main`, `master`, `trunk` or anything else — and want that one branch rather
than widening `branches` to `"*"` and mirroring everything.

The branch is resolved per sync from the remote's advertised `HEAD`, so if
upstream changes its default branch, the next sync follows it. The working tree
is checked out on that branch, which is the standard behaviour when no
`checkout` is configured.

It syncs the default branch and **nothing else**, so it cannot be combined with
the fields that name refs:

| Combined with | Result |
|---------------|--------|
| `branches` | Config error — the branch comes from the remote `HEAD`. |
| `tags` | Config error — the option means the default branch and nothing else. |
| `checkout` | Config error — the default branch is already checked out. |
| `openvox` | Config error — OpenVox syncs every matched branch into its own directory. |

A repo with `default_branch_only: true` also does not inherit `branches` or
`tags` from `defaults:`. Without that, a shared default would trip the rules
above and fail a config the operator never wrote that way.

Pruning interacts with it as follows:

- `prune: true` removes local branches other than the default branch, as usual.
The branch just synced is never treated as obsolete.
- `prune_stale` has no effect in this mode. The only configured branch is the
one just synced, and pruning it for being old would delete the sole branch
the repo exists to mirror.

## Stale Pruning

Stale pruning allows gfetch to remove inactive branches from the local mirror, even if they still match a configured pattern (like `*`). This is especially useful for preventing local storage from growing indefinitely when using wildcards or broad regex patterns.
Expand Down Expand Up @@ -221,7 +267,8 @@ The config is validated when loaded. The following rules are enforced:
- Repository names (map keys) must be ≤ 64 characters and contain only alphanumeric characters, dots, underscores, or hyphens.
- Each repo must have `url`, `local_path`, and `poll_interval` set.
- `poll_interval` must be at least `10s`.
- At least one of `branches` or `tags` must be non-empty.
- At least one of `branches` or `tags` must be non-empty, unless `default_branch_only: true` is set.
- `default_branch_only: true` is rejected alongside `branches`, `tags`, `checkout` or `openvox`. Each of those names the refs to sync, so they contradict the option rather than refine it — the config fails instead of silently ignoring what you wrote.
- All regex patterns must be valid Go regular expressions.
- If `url` is an SSH URL, `ssh_key_path` must be set and the file must exist.
- If `url` is an HTTPS URL, the repo must be publicly accessible (HTTP 200 on HEAD request).
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/obmondo/gfetch

go 1.26.6
go 1.27.0

require (
github.com/go-co-op/gocron/v2 v2.22.0
Expand Down
58 changes: 55 additions & 3 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ type RepoDefaults struct {
PollInterval Duration `yaml:"poll_interval"`
Branches []Pattern `yaml:"branches"`
Tags []Pattern `yaml:"tags"`
DefaultBranchOnly *bool `yaml:"default_branch_only"`
OpenVox *bool `yaml:"openvox"`
OpenVoxMaxWorkers *int `yaml:"openvox_max_workers"`
ProductionAlias *bool `yaml:"production_alias"`
Expand Down Expand Up @@ -168,6 +169,14 @@ func (r *RepoConfig) IsOpenVox() bool {
return r.OpenVox != nil && *r.OpenVox
}

// IsDefaultBranchOnly returns true if the repo should mirror only the remote's
// default branch — whatever HEAD points at — instead of matching against the
// branch patterns. Useful when the caller does not know the branch name up
// front and only wants the one branch, rather than widening patterns to "*".
func (r *RepoConfig) IsDefaultBranchOnly() bool {
return r.DefaultBranchOnly != nil && *r.DefaultBranchOnly
}

// HasProductionAlias returns true if the repo should have a production alias.
func (r *RepoConfig) HasProductionAlias() bool {
return r.ProductionAlias != nil && *r.ProductionAlias
Expand Down Expand Up @@ -353,10 +362,18 @@ func applyDefaults(repo *RepoConfig, defaults *RepoDefaults) {
if repo.LocalPath == "" && defaults.LocalPath != "" {
repo.LocalPath = defaults.LocalPath
}
if len(repo.Branches) == 0 && len(defaults.Branches) > 0 {
// Resolved before the patterns below, which depend on knowing it.
if defaults.DefaultBranchOnly != nil && repo.DefaultBranchOnly == nil {
repo.DefaultBranchOnly = defaults.DefaultBranchOnly
}
// A repo that syncs only its default branch has no use for inherited branch
// patterns, and inheriting them would trip the validation that rejects
// branches alongside default_branch_only — failing a config the operator
// never wrote that way.
if len(repo.Branches) == 0 && len(defaults.Branches) > 0 && !repo.IsDefaultBranchOnly() {
repo.Branches = defaults.Branches
}
if len(repo.Tags) == 0 && len(defaults.Tags) > 0 {
if len(repo.Tags) == 0 && len(defaults.Tags) > 0 && !repo.IsDefaultBranchOnly() {
repo.Tags = defaults.Tags
}
if defaults.OpenVox != nil && repo.OpenVox == nil {
Expand Down Expand Up @@ -457,7 +474,12 @@ func (c *Config) validateRepo(r *RepoConfig) error {
r.StaleAge = Duration(180 * 24 * time.Hour)
}

if len(r.Branches) == 0 && len(r.Tags) == 0 {
if err := validateDefaultBranchOnly(r); err != nil {
return err
}

// default_branch_only selects the branch by itself, so patterns are optional.
if len(r.Branches) == 0 && len(r.Tags) == 0 && !r.IsDefaultBranchOnly() {
return fmt.Errorf("repo %s: at least one branch or tag pattern is required", r.Name)
}

Expand All @@ -479,6 +501,8 @@ func (c *Config) validateRepo(r *RepoConfig) error {
return err
}

// validateDefaultBranchOnly has already rejected a checkout set alongside
// default_branch_only, so reaching here means the patterns are meaningful.
if r.Checkout != "" && !r.IsOpenVox() {
if !MatchesAny(r.Checkout, r.Branches) && !MatchesAny(r.Checkout, r.Tags) {
return fmt.Errorf("repo %s: checkout %q does not match any configured branch or tag pattern", r.Name, r.Checkout)
Expand All @@ -487,6 +511,34 @@ func (c *Config) validateRepo(r *RepoConfig) error {
return nil
}

// validateDefaultBranchOnly rejects the settings default_branch_only would
// otherwise silently override. The option takes the branch from the remote's
// advertised HEAD, so anything else naming a branch contradicts it rather than
// merely duplicating it — better to fail the config than to ignore what the
// operator wrote and leave them guessing why it had no effect.
//
// Tags are rejected too. The option means what it says - the default branch and
// nothing else - and a repo wanting tags as well should name its branch
// explicitly rather than half-using this.
func validateDefaultBranchOnly(r *RepoConfig) error {
if !r.IsDefaultBranchOnly() {
return nil
}
if r.IsOpenVox() {
return fmt.Errorf("repo %s: default_branch_only is for standard mode and cannot be combined with openvox, which syncs every matched branch", r.Name)
}
if len(r.Branches) > 0 {
return fmt.Errorf("repo %s: default_branch_only takes the branch from the remote HEAD; remove branches", r.Name)
}
if r.Checkout != "" {
return fmt.Errorf("repo %s: default_branch_only checks out the remote default branch; remove checkout", r.Name)
}
if len(r.Tags) > 0 {
return fmt.Errorf("repo %s: default_branch_only syncs the default branch and nothing else; remove tags", r.Name)
}
return nil
}

func validateOpenVoxOptions(r *RepoConfig) error {
if r.HasProductionAlias() && !r.IsOpenVox() {
return fmt.Errorf("repo %s: production_alias requires openvox=true", r.Name)
Expand Down
Loading