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
77 changes: 75 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Run git operations across dozens of repositories in parallel — checkout, pull,
- [branch delete](#gitm-branch-delete)
- [status](#gitm-status)
- [update](#gitm-update)
- [sync](#gitm-sync)
- [discard](#gitm-discard)
- [commit](#gitm-commit)
- [stash](#gitm-stash)
Expand Down Expand Up @@ -930,6 +931,77 @@ Done: 3 succeeded, 1 skipped

---

### `gitm sync`

Merge the latest **default branch** (`main`/`master`, auto-detected per repo) into the branch each repository is **currently on** — in parallel. This replaces the manual, per-repo routine of pulling the latest `master`/`main` and merging it into your working branch with `git merge master` by hand.

```
gitm sync [flags]
```

**Flags:**

| Flag | Short | Default | Description |
|---|---|---|---|
| `--repo` | `-r` | _(prompt)_ | Sync only the listed repository aliases (comma-separated). Skips the interactive picker. |
| `--all` | `-a` | `false` | Sync every registered repository without prompting. |

**Selection modes:**

| Invocation | Behaviour |
|---|---|
| `gitm sync` | Interactive — pick repositories via the TUI. |
| `gitm sync --repo a,b` | Sync only repos `a` and `b` (no prompt). |
| `gitm sync --all` | Sync every registered repository (no prompt). |

**Behaviour (per repository, in parallel):**

1. Detects the repository's default branch automatically (`main` or `master`, from the value stored at `repo add`).
2. **Skips** repos with uncommitted changes (stash or commit first).
3. **Skips** repos already on their default branch (use `gitm update` to pull instead).
4. Fetches the latest default branch from `origin`, then merges `origin/<default>` into the current branch (falls back to the local default branch when there is no remote).
5. **Merge conflicts are left in place** — the repo is reported and kept in its merging state so you can resolve the conflicts and commit. A conflict is not treated as a failure; the command still exits 0.
6. Streams results live with a summary, followed by a list of any repos left with conflicts.

**Use case:** Your feature branch has drifted behind `master`/`main` across several repos and you want to merge the latest changes into each one in a single step.

**Examples:**

```bash
# Interactively pick repos to sync
gitm sync

# Sync every repo
gitm sync --all

# Sync specific repos by alias
gitm sync --repo=api-gateway,auth-service

# Short form
gitm sync -r api-gateway
```

**Example output:**

```
Merging default branch into the current branch of 3 repository(ies)…

[api-gateway ] ✓ merged main into feature/JIRA-456 — fast-forward
[auth-service ] ⚠ SKIPPED: currently on default branch "main" — nothing to merge (use `gitm update` to pull)
[frontend ] ⚠ SKIPPED: merge conflict — 2 file(s) to resolve manually

Done: 1 succeeded, 2 skipped

1 repository(ies) have merge conflicts left for you to resolve:
- frontend (/Users/me/code/frontend)
conflict: src/app.tsx
conflict: package.json

Resolve the conflicts in each repo, then `git add` + `git commit` (or `git merge --abort`).
```

---

### `gitm commit`

Interactively stage files and commit across dirty repositories. Walks you through each selected repository **sequentially** — pick files, write a message, and push. Use `--repo` to skip the selection UI and target specific repositories by alias.
Expand Down Expand Up @@ -1500,8 +1572,8 @@ go test ./internal/cli/... -v -race -run TestResetSoft

| Metric | Count |
|---|---|
| Test files | 38 |
| Test functions | 330 |
| Test files | 40 |
| Test functions | 369 |
| Language | Go |

---
Expand All @@ -1523,6 +1595,7 @@ cli-git-commands/
│ │ ├── branch.go # branch create/rename/delete
│ │ ├── status.go # status
│ │ ├── update.go # update
│ │ ├── sync.go # sync (merge default branch into current branch)
│ │ ├── discard.go # discard
│ │ ├── commit.go # commit
│ │ ├── stash.go # stash / stash apply / stash pop / stash list
Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ All multi-repo operations run concurrently for speed.`,
root.AddCommand(branchCmd())
root.AddCommand(statusCmd())
root.AddCommand(updateCmd())
root.AddCommand(syncCmd())
root.AddCommand(discardCmd())
root.AddCommand(commitCmd())
root.AddCommand(stashCmd())
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestRootCommandHasSubcommands(t *testing.T) {

func TestRootCommandSubcommandNames(t *testing.T) {
cmd := Root("test")
expectedCommands := []string{"repo", "checkout", "branch", "status", "update", "discard", "commit", "stash", "reset", "track", "untrack", "upgrade"}
expectedCommands := []string{"repo", "checkout", "branch", "status", "update", "sync", "discard", "commit", "stash", "reset", "track", "untrack", "upgrade"}

actual := make(map[string]bool)
for _, sc := range cmd.Commands() {
Expand Down
227 changes: 227 additions & 0 deletions internal/cli/sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package cli

import (
"fmt"
"strings"
"sync"

"github.com/spf13/cobra"

"github.com/alexandreafj/gitm/internal/db"
"github.com/alexandreafj/gitm/internal/git"
"github.com/alexandreafj/gitm/internal/runner"
)

func syncCmd() *cobra.Command {
var (
repoAliases []string
selectAll bool
)

cmd := &cobra.Command{
Use: "sync",
Short: "Merge the latest default branch (main/master) into the current branch",
Long: `Bring the branch you are working on up to date with the latest default
branch (main or master, auto-detected per repository) by merging it in —
across one or many repositories at once.

For each selected repository, gitm:
1. Fetches the latest default branch from origin.
2. Merges it into whatever branch the repository is currently on.

This replaces the manual, per-repo routine of pulling the latest master/main
and merging it into your working branch with "git merge master" by hand.

Selection:

gitm sync
Interactive: pick repositories via the TUI.

gitm sync --repo api-gateway,auth-service
Sync only the named repositories (no prompt).

gitm sync --all
Sync every registered repository (no prompt).

Repositories are skipped when:
- they have uncommitted changes (stash or commit first), or
- they are already on their default branch (use "gitm update" to pull).

Merge conflicts are left in place so you can resolve them yourself: the repo is
reported and kept in its merging state — resolve the conflicts and commit.`,
Example: ` gitm sync
gitm sync --all
gitm sync --repo=api-gateway,auth-service
gitm sync -r api-gateway`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runSyncWithUI(liveUI{}, selectAll, repoAliases)
},
}

cmd.Flags().StringSliceVarP(&repoAliases, "repo", "r", nil,
"Limit sync to specific repository aliases (comma-separated)")
cmd.Flags().BoolVarP(&selectAll, "all", "a", false,
"Sync all registered repositories without prompting")

return cmd
}

func runSyncWithUI(ui ui, selectAll bool, repoAliases []string) error {
allRepos, err := resolveRepos(repoAliases)
if err != nil {
return err
}
if len(allRepos) == 0 {
fmt.Println("No repositories registered. Run `gitm repo add <path>` to add one.")
return nil
}

var chosen []*db.Repository
switch {
case len(repoAliases) > 0:
chosen = allRepos
case selectAll:
chosen = allRepos
default:
chosen, err = ui.MultiSelect(
allRepos,
"Select repositories to sync with their default branch",
false,
nil,
)
if err != nil {
return err
}
}

if len(chosen) == 0 {
return nil
}

fmt.Printf("\nMerging default branch into the current branch of %d repository(ies)…\n\n", len(chosen))

// Conflicts are recorded here rather than treated as hard failures, so a
// clear follow-up block can be printed once the parallel run finishes. The
// mutex guards appends because runner.Run invokes op from many goroutines.
var (
mu sync.Mutex
conflicts []conflictedRepo
)

results := runner.Run(chosen, func(repo *db.Repository) (string, string, error) {
dirty, err := git.IsDirty(repo.Path)
if err != nil {
return "", "", fmt.Errorf("git status: %w", err)
}
if dirty {
return "", "uncommitted changes — stash or commit first", nil
}

cur, err := git.CurrentBranch(repo.Path)
if err != nil {
return "", "", fmt.Errorf("current branch: %w", err)
}

def := repo.DefaultBranch
if cur == def {
return "", fmt.Sprintf("currently on default branch %q — nothing to merge (use `gitm update` to pull)", def), nil
}

// Fetch the latest default branch. A failure (no remote / offline) is not
// fatal — we fall back to the local branch rather than a stale
// remote-tracking ref (see mergeRef). The outcome is folded into the
// result message instead of printed here, so it stays synchronized with
// the runner's own output.
fetched := git.FetchBranch(repo.Path, def) == nil

ref := mergeRef(repo.Path, def, fetched)
if ref == "" {
return "", fmt.Sprintf("default branch %q not found locally or on origin", def), nil
}

out, mergeErr := git.Merge(repo.Path, ref)
if mergeErr != nil {
unmerged, unmergedErr := git.UnmergedFiles(repo.Path)
if unmergedErr == nil && len(unmerged) > 0 {
mu.Lock()
conflicts = append(conflicts, conflictedRepo{alias: repo.Alias, path: repo.Path, files: unmerged})
mu.Unlock()
return "", fmt.Sprintf("merge conflict — %d file(s) to resolve manually", len(unmerged)), nil
}
return "", "", fmt.Errorf("merge %s: %w", ref, mergeErr)
}

msg := fmt.Sprintf("merged %s into %s — %s", def, cur, summariseMerge(out))
if !fetched {
msg += " (fetch failed; merged without refresh)"
}
return msg, "", nil
})

printConflicts(conflicts)

// Merge conflicts are intentional skips, not failures. Only genuine errors
// (status/branch failures, non-conflict merge errors) make the command exit
// non-zero, matching the other multi-repo commands.
if runner.HasErrors(results) {
return fmt.Errorf("%d repository(ies) failed to sync", runner.ErrorCount(results))
}
return nil
}

type conflictedRepo struct {
alias string
path string
files []string
}

// mergeRef returns the ref to merge for the default branch. When the fetch
// succeeded it prefers the freshly-updated remote-tracking origin/<def>;
// otherwise it falls back to the local branch so an offline/failed fetch never
// silently merges a stale remote-tracking ref. As a last resort (no local
// branch) it uses origin/<def> if one exists.
func mergeRef(path, def string, fetched bool) string {
originRef := "origin/" + def
if fetched && git.BranchExists(path, originRef) {
return originRef
}
if git.BranchExists(path, def) {
return def
}
if git.BranchExists(path, originRef) {
return originRef
}
return ""
}

// summariseMerge condenses git merge output into a short status string.
func summariseMerge(out string) string {
out = strings.TrimSpace(out)
switch {
case out == "":
return "merged"
case strings.Contains(out, "Already up to date"), strings.Contains(out, "Already up-to-date"):
return "already up to date"
case strings.Contains(out, "Fast-forward"):
return "fast-forward"
default:
return "merged"
}
}

// printConflicts lists repositories left in a conflicted state, with paths, so
// the user can resolve each one and commit.
func printConflicts(conflicts []conflictedRepo) {
if len(conflicts) == 0 {
return
}
fmt.Printf("\n%d repository(ies) have merge conflicts left for you to resolve:\n", len(conflicts))
for _, c := range conflicts {
fmt.Printf(" - %s (%s)\n", c.alias, c.path)
for _, f := range c.files {
fmt.Printf(" conflict: %s\n", f)
}
}
fmt.Println("\nResolve the conflicts in each repo, then `git add` + `git commit` (or `git merge --abort`).")
}
Loading
Loading