From 1ecdf6ce4529479a3979d55544b9c4589912008c Mon Sep 17 00:00:00 2001 From: Alexandre Ferrreira Date: Fri, 29 May 2026 09:12:40 +0100 Subject: [PATCH 1/4] feat: add sync command to merge default branch into current branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updating a feature branch with the latest master/main was a manual chore: run `gitm checkout master -r `, then cd into each repo and `git merge master` by hand, repeated per repo. `gitm sync` automates this across selected repos in parallel — interactively, or via --repo/--all. For each repo it auto-detects the default branch (main/master) from the stored value, skips dirty repos and repos already on the default branch, fetches the latest default from origin, and merges origin/ into the current branch. Merge conflicts are intentionally left in place and reported so the user can resolve them, rather than aborting or failing the whole run. Co-Authored-By: Claude Opus 4.8 --- README.md | 73 ++++++++++++ internal/cli/root.go | 1 + internal/cli/root_test.go | 2 +- internal/cli/sync.go | 208 +++++++++++++++++++++++++++++++++ internal/cli/sync_test.go | 234 +++++++++++++++++++++++++++++++++++++ internal/git/git.go | 32 +++++ internal/git/merge_test.go | 78 +++++++++++++ 7 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 internal/cli/sync.go create mode 100644 internal/cli/sync_test.go create mode 100644 internal/git/merge_test.go diff --git a/README.md b/README.md index 23076f4..778df0e 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 loop of `gitm checkout master`, `cd` into the repo folder, and `git merge master` for every repository. + +``` +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/` 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. @@ -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 diff --git a/internal/cli/root.go b/internal/cli/root.go index 6d5916b..644673c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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()) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 5c49e04..36fd53e 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -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() { diff --git a/internal/cli/sync.go b/internal/cli/sync.go new file mode 100644 index 0000000..b07030b --- /dev/null +++ b/internal/cli/sync.go @@ -0,0 +1,208 @@ +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 loop of "gitm checkout master", cd into the repo +folder, and "git merge master" for every repository. + +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 ` 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 + ) + + runner.Run(chosen, func(repo *db.Repository) (string, string, error) { + dirty, err := git.IsDirtyTrackedOnly(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 — fall back to merging the local default branch. + if fetchErr := git.FetchBranch(repo.Path, def); fetchErr != nil { + fmt.Printf(" warning: fetch %s failed on %s: %v\n", def, repo.Alias, fetchErr) + } + + ref := mergeRef(repo.Path, def) + 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) + } + + return fmt.Sprintf("merged %s into %s — %s", def, cur, summariseMerge(out)), "", nil + }) + + printConflicts(conflicts) + return nil +} + +type conflictedRepo struct { + alias string + path string + files []string +} + +// mergeRef returns the most up-to-date ref to merge for the default branch: +// the remote-tracking origin/ when it exists, otherwise the local branch. +func mergeRef(path, def string) string { + if git.BranchExists(path, "origin/"+def) { + return "origin/" + def + } + if git.BranchExists(path, def) { + return def + } + 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`).") +} diff --git a/internal/cli/sync_test.go b/internal/cli/sync_test.go new file mode 100644 index 0000000..cb67467 --- /dev/null +++ b/internal/cli/sync_test.go @@ -0,0 +1,234 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/alexandreafj/gitm/internal/db" +) + +func TestSyncCmdExists(t *testing.T) { + if syncCmd() == nil { + t.Fatal("syncCmd() returned nil") + } +} + +func TestSyncCmdHasUse(t *testing.T) { + if cmd := syncCmd(); cmd.Use != "sync" { + t.Errorf("syncCmd Use = %q, want %q", cmd.Use, "sync") + } +} + +func TestSyncCmdHasShort(t *testing.T) { + if syncCmd().Short == "" { + t.Error("syncCmd has empty Short description") + } +} + +func TestSyncCmdIsRunnable(t *testing.T) { + if syncCmd().RunE == nil { + t.Error("syncCmd has no RunE function") + } +} + +func TestSyncCmdHasRepoFlag(t *testing.T) { + f := syncCmd().Flags().Lookup("repo") + if f == nil { + t.Fatal("syncCmd missing --repo flag") + } + if f.Shorthand != "r" { + t.Errorf("--repo shorthand = %q, want %q", f.Shorthand, "r") + } +} + +func TestSyncCmdHasAllFlag(t *testing.T) { + f := syncCmd().Flags().Lookup("all") + if f == nil { + t.Fatal("syncCmd missing --all flag") + } + if f.Shorthand != "a" { + t.Errorf("--all shorthand = %q, want %q", f.Shorthand, "a") + } +} + +// advanceOriginMain pushes a new commit to origin's main branch by cloning the +// bare origin, committing, and pushing — simulating master moving ahead. +func advanceOriginMain(t *testing.T, originDir, filename, content string) { + t.Helper() + clone := cloneRepo(t, originDir) + mustRunGit(t, clone, "config", "user.email", "test@example.com") + mustRunGit(t, clone, "config", "user.name", "Test User") + mustRunGit(t, clone, "config", "commit.gpgsign", "false") + writeFile(t, clone, filename, content) + mustRunGit(t, clone, "add", ".") + mustRunGit(t, clone, "commit", "-m", "advance main: "+filename) + mustRunGit(t, clone, "push", "origin", "main") +} + +func TestRunSync_MergesDefaultIntoCurrent(t *testing.T) { + database = setupTestDB(t) + dir, originDir, _ := initRepoWithRemote(t) + + mustRunGit(t, dir, "checkout", "-b", "feature/x") + advanceOriginMain(t, originDir, "frommain.go", "package main\n") + + repo, err := database.AddRepository("repo1", "repo1", dir, "main") + if err != nil { + t.Fatalf("AddRepository: %v", err) + } + + if err := runSyncWithUI(fakeUI{selectRepos: []*db.Repository{repo}}, false, nil); err != nil { + t.Fatalf("runSyncWithUI: %v", err) + } + + if _, statErr := os.Stat(filepath.Join(dir, "frommain.go")); statErr != nil { + t.Errorf("expected frommain.go to be merged from origin/main: %v", statErr) + } + if head := gitCurrentBranch(t, dir); head != "feature/x" { + t.Fatalf("expected to stay on feature/x, got %q", head) + } +} + +func TestRunSync_SkipsDirty(t *testing.T) { + database = setupTestDB(t) + dir, originDir, _ := initRepoWithRemote(t) + + mustRunGit(t, dir, "checkout", "-b", "feature/x") + advanceOriginMain(t, originDir, "frommain.go", "package main\n") + + // Uncommitted change to a tracked file → repo must be skipped. + writeFile(t, dir, "README.md", "dirty change\n") + + repo, err := database.AddRepository("repo1", "repo1", dir, "main") + if err != nil { + t.Fatalf("AddRepository: %v", err) + } + + if err := runSyncWithUI(fakeUI{selectRepos: []*db.Repository{repo}}, false, nil); err != nil { + t.Fatalf("runSyncWithUI: %v", err) + } + + // Nothing should have been merged into the dirty repo. + if _, statErr := os.Stat(filepath.Join(dir, "frommain.go")); statErr == nil { + t.Error("dirty repo should have been skipped, but origin/main was merged") + } + if head := gitCurrentBranch(t, dir); head != "feature/x" { + t.Fatalf("expected to stay on feature/x, got %q", head) + } +} + +func TestRunSync_SkipsWhenOnDefaultBranch(t *testing.T) { + database = setupTestDB(t) + dir, originDir, _ := initRepoWithRemote(t) + + // Stay on the default branch (main); advance origin/main. + advanceOriginMain(t, originDir, "frommain.go", "package main\n") + + repo, err := database.AddRepository("repo1", "repo1", dir, "main") + if err != nil { + t.Fatalf("AddRepository: %v", err) + } + + if err := runSyncWithUI(fakeUI{selectRepos: []*db.Repository{repo}}, false, nil); err != nil { + t.Fatalf("runSyncWithUI: %v", err) + } + + // On the default branch sync is a no-op (it does not even merge origin/main). + if _, statErr := os.Stat(filepath.Join(dir, "frommain.go")); statErr == nil { + t.Error("on default branch sync should be a no-op, but origin/main was merged") + } + if head := gitCurrentBranch(t, dir); head != "main" { + t.Fatalf("expected to stay on main, got %q", head) + } +} + +func TestRunSync_LeavesConflictInPlace(t *testing.T) { + database = setupTestDB(t) + dir, originDir, _ := initRepoWithRemote(t) + + // Establish a shared file on main + origin. + writeFile(t, dir, "shared.txt", "base\n") + mustRunGit(t, dir, "add", "shared.txt") + mustRunGit(t, dir, "commit", "-m", "add shared on main") + mustRunGit(t, dir, "push", "origin", "main") + + // Feature branch edits the shared file one way… + mustRunGit(t, dir, "checkout", "-b", "feature/x") + writeFile(t, dir, "shared.txt", "feature change\n") + mustRunGit(t, dir, "add", "shared.txt") + mustRunGit(t, dir, "commit", "-m", "feature edit") + + // …while origin/main edits the same lines another way. + advanceOriginMain(t, originDir, "shared.txt", "main change\n") + + repo, err := database.AddRepository("repo1", "repo1", dir, "main") + if err != nil { + t.Fatalf("AddRepository: %v", err) + } + + // A conflict is an expected outcome, not a hard failure → no error returned. + if err := runSyncWithUI(fakeUI{selectRepos: []*db.Repository{repo}}, false, nil); err != nil { + t.Fatalf("runSyncWithUI returned error on conflict (should be nil): %v", err) + } + + // The repo must be left in a merging state for manual resolution. + if _, statErr := os.Stat(filepath.Join(dir, ".git", "MERGE_HEAD")); statErr != nil { + t.Errorf("expected MERGE_HEAD (conflict left in place): %v", statErr) + } + if head := gitCurrentBranch(t, dir); head != "feature/x" { + t.Fatalf("expected to stay on feature/x, got %q", head) + } +} + +func TestRunSync_RepoFlagBypassesTUI(t *testing.T) { + database = setupTestDB(t) + dir, originDir, _ := initRepoWithRemote(t) + + mustRunGit(t, dir, "checkout", "-b", "feature/x") + advanceOriginMain(t, originDir, "frommain.go", "package main\n") + + if _, err := database.AddRepository("repo1", "repo1", dir, "main"); err != nil { + t.Fatalf("AddRepository: %v", err) + } + + // selectErr ensures the test fails if MultiSelect is consulted — proving + // --repo bypasses the interactive picker. + ui := fakeUI{selectErr: fmt.Errorf("MultiSelect should not be called with --repo")} + if err := runSyncWithUI(ui, false, []string{"repo1"}); err != nil { + t.Fatalf("runSyncWithUI: %v", err) + } + + if _, statErr := os.Stat(filepath.Join(dir, "frommain.go")); statErr != nil { + t.Errorf("expected frommain.go to be merged: %v", statErr) + } +} + +func TestRunSync_AllFlagBypassesTUI(t *testing.T) { + database = setupTestDB(t) + dir, originDir, _ := initRepoWithRemote(t) + + mustRunGit(t, dir, "checkout", "-b", "feature/x") + advanceOriginMain(t, originDir, "frommain.go", "package main\n") + + if _, err := database.AddRepository("repo1", "repo1", dir, "main"); err != nil { + t.Fatalf("AddRepository: %v", err) + } + + ui := fakeUI{selectErr: fmt.Errorf("MultiSelect should not be called with --all")} + if err := runSyncWithUI(ui, true, nil); err != nil { + t.Fatalf("runSyncWithUI: %v", err) + } + + if _, statErr := os.Stat(filepath.Join(dir, "frommain.go")); statErr != nil { + t.Errorf("expected frommain.go to be merged: %v", statErr) + } +} + +func TestRunSync_NoRepos(t *testing.T) { + database = setupTestDB(t) + if err := runSyncWithUI(fakeUI{}, false, nil); err != nil { + t.Fatalf("runSyncWithUI with no repos: %v", err) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index 9a3164e..2539d65 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -238,6 +238,38 @@ func Pull(path string) (string, error) { return run(path, "pull", "--ff-only") } +// Merge merges ref (e.g. "origin/main" or "main") into the current branch and +// returns git's output. --no-edit suppresses the merge-commit message editor, +// which would otherwise hang the non-interactive multi-repo runner. On a merge +// conflict git exits non-zero and leaves the working tree in a merging state; +// callers detect that case with UnmergedFiles rather than treating it as a hard +// failure. +func Merge(path, ref string) (string, error) { + return run(path, "merge", "--no-edit", ref) +} + +// UnmergedFiles returns the paths with merge conflicts (unmerged index entries). +// A non-empty result after a failed Merge means the merge stopped on conflicts +// and left the tree in a conflicted state for manual resolution. git diff exits +// zero here, so the signal survives even though run() drops stdout on a failed +// merge. +func UnmergedFiles(path string) ([]string, error) { + out, err := run(path, "diff", "--name-only", "--diff-filter=U") + if err != nil { + return nil, err + } + if strings.TrimSpace(out) == "" { + return nil, nil + } + var files []string + for _, l := range strings.Split(out, "\n") { + if strings.TrimSpace(l) != "" { + files = append(files, strings.TrimSpace(l)) + } + } + return files, nil +} + // CreateBranch creates and checks out a new branch from the current HEAD. func CreateBranch(path, branch string) error { _, err := run(path, "checkout", "-b", branch) diff --git a/internal/git/merge_test.go b/internal/git/merge_test.go new file mode 100644 index 0000000..8a8511b --- /dev/null +++ b/internal/git/merge_test.go @@ -0,0 +1,78 @@ +package git_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/alexandreafj/gitm/internal/git" +) + +func TestMerge(t *testing.T) { + dir := initRepo(t) + + // Branch off main, then advance main with a new file. + mustRunGit(t, dir, "checkout", "-b", "feature") + mustRunGit(t, dir, "checkout", "main") + makeCommit(t, dir, "added.go", "package main\n", "add on main") + + // Merge main into feature; feature should gain added.go. + mustRunGit(t, dir, "checkout", "feature") + if _, err := git.Merge(dir, "main"); err != nil { + t.Fatalf("Merge: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, "added.go")); err != nil { + t.Errorf("expected added.go to be merged into feature: %v", err) + } + + conflicts, err := git.UnmergedFiles(dir) + if err != nil { + t.Fatalf("UnmergedFiles: %v", err) + } + if len(conflicts) != 0 { + t.Errorf("expected no conflicts after clean merge, got %v", conflicts) + } +} + +func TestMergeConflict(t *testing.T) { + dir := initRepo(t) + + // Both branches modify the same file differently → guaranteed conflict. + makeCommit(t, dir, "shared.txt", "base\n", "base content") + mustRunGit(t, dir, "checkout", "-b", "feature") + makeCommit(t, dir, "shared.txt", "feature change\n", "feature edit") + + mustRunGit(t, dir, "checkout", "main") + makeCommit(t, dir, "shared.txt", "main change\n", "main edit") + + mustRunGit(t, dir, "checkout", "feature") + if _, err := git.Merge(dir, "main"); err == nil { + t.Fatal("expected Merge to fail on conflicting changes") + } + + conflicts, err := git.UnmergedFiles(dir) + if err != nil { + t.Fatalf("UnmergedFiles: %v", err) + } + if len(conflicts) != 1 || conflicts[0] != "shared.txt" { + t.Errorf("expected [shared.txt] unmerged, got %v", conflicts) + } + + // The repo must be left in a merging state for manual resolution. + if _, err := os.Stat(filepath.Join(dir, ".git", "MERGE_HEAD")); err != nil { + t.Errorf("expected MERGE_HEAD to exist (merge left in place): %v", err) + } +} + +func TestUnmergedFilesClean(t *testing.T) { + dir := initRepo(t) + + conflicts, err := git.UnmergedFiles(dir) + if err != nil { + t.Fatalf("UnmergedFiles: %v", err) + } + if len(conflicts) != 0 { + t.Errorf("expected no unmerged files in clean repo, got %v", conflicts) + } +} From 5b21cd26f1bc71dd86db08ea11787f3a96c27cc5 Mon Sep 17 00:00:00 2001 From: Alexandre Ferrreira Date: Fri, 29 May 2026 09:18:38 +0100 Subject: [PATCH 2/4] docs: refresh test stats to 40 files / 367 functions The Test Stats table had drifted (38 files / 330 functions): test functions were added since the stats landed in #34 without updating the count, and this branch adds two test files (merge_test.go, sync_test.go). Counts exclude the single TestMain harness entry point, which is not a test. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 778df0e..ce91a4d 100644 --- a/README.md +++ b/README.md @@ -1572,8 +1572,8 @@ go test ./internal/cli/... -v -race -run TestResetSoft | Metric | Count | |---|---| -| Test files | 38 | -| Test functions | 330 | +| Test files | 40 | +| Test functions | 367 | | Language | Go | --- From 0de75af81f1944bab4fac23111bc5d41384605ad Mon Sep 17 00:00:00 2001 From: Alexandre Ferrreira Date: Fri, 29 May 2026 09:29:17 +0100 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20PR=20review=20on=20sync=20?= =?UTF-8?q?=E2=80=94=20clean-tree=20check,=20error=20exit,=20fetch=20fallb?= =?UTF-8?q?ack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves four review findings on the new sync command: - Require a fully clean working tree (git.IsDirty) instead of tracked-only. A merge can be blocked by an untracked file that collides with a path the default branch introduces, so untracked changes must skip the repo too — IsDirtyTrackedOnly is documented as safe only when untracked files pose no conflict risk, which is not the case for a merge. - Return a non-zero error when runner.HasErrors, matching the other multi-repo commands. Real failures (status/branch errors, non-conflict merge errors) now surface; merge conflicts remain intentional skips that still exit 0. - Stop printing the fetch-failure warning from inside the parallel worker (which could interleave with the runner's synchronized output) and fold it into the returned result message instead. - Prefer origin/ only when the fetch succeeded; on a failed/offline fetch fall back to the local default branch rather than silently merging a stale remote-tracking ref. Adds tests for the untracked-skip and error-exit paths; refreshes test stats. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- internal/cli/sync.go | 45 ++++++++++++++++++++++++++++----------- internal/cli/sync_test.go | 45 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ce91a4d..aef02cb 100644 --- a/README.md +++ b/README.md @@ -1573,7 +1573,7 @@ go test ./internal/cli/... -v -race -run TestResetSoft | Metric | Count | |---|---| | Test files | 40 | -| Test functions | 367 | +| Test functions | 369 | | Language | Go | --- diff --git a/internal/cli/sync.go b/internal/cli/sync.go index b07030b..d3e228d 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -109,8 +109,8 @@ func runSyncWithUI(ui ui, selectAll bool, repoAliases []string) error { conflicts []conflictedRepo ) - runner.Run(chosen, func(repo *db.Repository) (string, string, error) { - dirty, err := git.IsDirtyTrackedOnly(repo.Path) + 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) } @@ -129,12 +129,13 @@ func runSyncWithUI(ui ui, selectAll bool, repoAliases []string) error { } // Fetch the latest default branch. A failure (no remote / offline) is not - // fatal — fall back to merging the local default branch. - if fetchErr := git.FetchBranch(repo.Path, def); fetchErr != nil { - fmt.Printf(" warning: fetch %s failed on %s: %v\n", def, repo.Alias, fetchErr) - } + // 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) + ref := mergeRef(repo.Path, def, fetched) if ref == "" { return "", fmt.Sprintf("default branch %q not found locally or on origin", def), nil } @@ -151,10 +152,21 @@ func runSyncWithUI(ui ui, selectAll bool, repoAliases []string) error { return "", "", fmt.Errorf("merge %s: %w", ref, mergeErr) } - return fmt.Sprintf("merged %s into %s — %s", def, cur, summariseMerge(out)), "", nil + 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 } @@ -164,15 +176,22 @@ type conflictedRepo struct { files []string } -// mergeRef returns the most up-to-date ref to merge for the default branch: -// the remote-tracking origin/ when it exists, otherwise the local branch. -func mergeRef(path, def string) string { - if git.BranchExists(path, "origin/"+def) { - return "origin/" + def +// mergeRef returns the ref to merge for the default branch. When the fetch +// succeeded it prefers the freshly-updated remote-tracking origin/; +// 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/ 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 "" } diff --git a/internal/cli/sync_test.go b/internal/cli/sync_test.go index cb67467..b477261 100644 --- a/internal/cli/sync_test.go +++ b/internal/cli/sync_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "github.com/alexandreafj/gitm/internal/db" @@ -226,6 +227,50 @@ func TestRunSync_AllFlagBypassesTUI(t *testing.T) { } } +func TestRunSync_SkipsUntrackedChanges(t *testing.T) { + database = setupTestDB(t) + dir, originDir, _ := initRepoWithRemote(t) + + mustRunGit(t, dir, "checkout", "-b", "feature/x") + advanceOriginMain(t, originDir, "frommain.go", "package main\n") + + // An untracked file can collide with a path the merge introduces, so sync + // requires a fully clean tree and skips repos with untracked files too. + writeFile(t, dir, "scratch.txt", "untracked\n") + + repo, err := database.AddRepository("repo1", "repo1", dir, "main") + if err != nil { + t.Fatalf("AddRepository: %v", err) + } + + if err := runSyncWithUI(fakeUI{selectRepos: []*db.Repository{repo}}, false, nil); err != nil { + t.Fatalf("runSyncWithUI: %v", err) + } + + if _, statErr := os.Stat(filepath.Join(dir, "frommain.go")); statErr == nil { + t.Error("repo with untracked changes should be skipped, but origin/main was merged") + } +} + +func TestRunSync_ReturnsErrorOnFailure(t *testing.T) { + database = setupTestDB(t) + + // A repo whose path does not exist makes git fail — a genuine error that + // must surface (exit non-zero), unlike a merge conflict which is a skip. + repo, err := database.AddRepository("broken", "broken", "/nonexistent/path", "main") + if err != nil { + t.Fatalf("AddRepository: %v", err) + } + + err = runSyncWithUI(fakeUI{selectRepos: []*db.Repository{repo}}, false, nil) + if err == nil { + t.Fatal("expected error when a repo fails, got nil") + } + if !strings.Contains(err.Error(), "failed to sync") { + t.Errorf("error = %q, want to contain \"failed to sync\"", err.Error()) + } +} + func TestRunSync_NoRepos(t *testing.T) { database = setupTestDB(t) if err := runSyncWithUI(fakeUI{}, false, nil); err != nil { From 04806fa840c627ad572285de74b18e2286535ab0 Mon Sep 17 00:00:00 2001 From: Alexandre Ferrreira Date: Fri, 29 May 2026 10:01:46 +0100 Subject: [PATCH 4/4] docs: fix misleading "checkout master then merge master" wording The sync help text and README described the manual workflow as `gitm checkout master` followed by `git merge master`, but that sequence leaves you on the default branch and makes the merge a no-op. Reword both to describe what sync actually replaces: pulling the latest master/main and merging it into your working branch by hand, per repo. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- internal/cli/sync.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index aef02cb..667e0b2 100644 --- a/README.md +++ b/README.md @@ -933,7 +933,7 @@ 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 loop of `gitm checkout master`, `cd` into the repo folder, and `git merge master` for every repository. +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] diff --git a/internal/cli/sync.go b/internal/cli/sync.go index d3e228d..cfc1c5d 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -29,8 +29,8 @@ 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 loop of "gitm checkout master", cd into the repo -folder, and "git merge master" for every repository. +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: