diff --git a/README.md b/README.md index bf0bdf0..52ccb4c 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,32 @@ hm sync [repository...] [flags] | `--parallel` | Concurrent operations (default: 4) | | `--dry-run` | Show what would be synced | +### lock + +Update the lock file without touching repository checkouts. + +```bash +hm lock update [repository...] [flags] # Pin to latest commit on the configured branch +hm lock adopt [repository...] [flags] # Pin to the current local HEAD +``` + +`update` resolves each repository's configured branch against its remote +(`git ls-remote`) and records the tip commit. Repositories whose config pins +a commit or tag are skipped. + +`adopt` records each repository's current local HEAD — for example after +committing and pushing from within a checkout. A HEAD that has not been +pushed to origin cannot be reproduced by `hm sync --locked` elsewhere, so it +is refused unless `--force` is given. + +| Flag | Description | +|------|-------------| +| `-p, --project` | Apply to repositories in a project | +| `-t, --tag` | Apply to repositories with a tag | +| `--dry-run` | Show what would change without writing the lock file | +| `--sync` | (`update` only) Also check out the new pins afterwards | +| `--force` | (`adopt` only) Pin a HEAD that has not been pushed to origin | + ### status Show repository status. @@ -272,6 +298,8 @@ tags = ["production"] Harbormaster maintains a lock file (`.harbormaster.lock`) that records exact commit SHAs for reproducible syncs. Use `hm sync --locked` to sync to the locked state. +Update the pins with `hm lock update` (latest commit on each configured branch) or `hm lock adopt` (current local HEAD); neither touches your checkouts. A plain `hm sync` also refreshes the lock file as a side effect of syncing. + ## Examples ```bash @@ -296,6 +324,12 @@ hm sync --dry-run # Reproducible sync using lock file hm sync --locked +# Bump a pin to the latest commit on its branch (checkout untouched) +hm lock update cports + +# Record the local HEAD you just committed and pushed +hm lock adopt cragutils + # Create a project with initial repositories hm project add backend --repos=api,database --tags=production diff --git a/cmd/harbormaster/e2e_test.go b/cmd/harbormaster/e2e_test.go index 41797fb..35bf759 100644 --- a/cmd/harbormaster/e2e_test.go +++ b/cmd/harbormaster/e2e_test.go @@ -917,7 +917,7 @@ func TestE2E_Help(t *testing.T) { stdout := mustRun(t, workDir, "--help") - expectedCommands := []string{"init", "sync", "status", "list", "add", "remove", "work", "project"} + expectedCommands := []string{"init", "sync", "status", "list", "add", "remove", "work", "project", "lock"} for _, cmd := range expectedCommands { if !strings.Contains(stdout, cmd) { t.Errorf("expected '%s' in help output", cmd) @@ -945,3 +945,102 @@ func TestE2E_NoColorFlag(t *testing.T) { t.Errorf("expected no ANSI escapes with --no-color, got: %q", stdout) } } + +// --------------------------------------------------------------------------- +// lock +// --------------------------------------------------------------------------- + +func lockFileContains(t *testing.T, workDir, sha string) bool { + t.Helper() + content, err := os.ReadFile(filepath.Join(workDir, ".harbormaster.lock")) + if err != nil { + t.Fatalf("failed to read lock file: %v", err) + } + return strings.Contains(string(content), sha) +} + +func TestE2E_LockUpdate(t *testing.T) { + requireGit(t) + workDir := t.TempDir() + sourceDir := setupSyncedWorkspace(t, workDir, "repo") + checkoutDir := filepath.Join(workDir, "repo") + syncedSHA := gitIn(t, checkoutDir, "rev-parse", "HEAD") + + // Advance the source branch. + commitFileIn(t, sourceDir, "new.txt", "new content") + newSHA := gitIn(t, sourceDir, "rev-parse", "HEAD") + + // --dry-run reports the change but does not write the lock file. + stdout := mustRun(t, workDir, "lock", "update", "--dry-run") + if !strings.Contains(stdout, "Dry run") { + t.Errorf("expected dry-run notice, got: %s", stdout) + } + if lockFileContains(t, workDir, newSHA) { + t.Error("dry-run must not write the new SHA to the lock file") + } + + // The real run updates the lock file but not the checkout. + stdout = mustRun(t, workDir, "lock", "update") + if !strings.Contains(stdout, "->") { + t.Errorf("expected old -> new output, got: %s", stdout) + } + if !lockFileContains(t, workDir, newSHA) { + t.Error("expected lock file to contain the new branch tip") + } + if got := gitIn(t, checkoutDir, "rev-parse", "HEAD"); got != syncedSHA { + t.Errorf("lock update moved the checkout to %s, want untouched %s", got, syncedSHA) + } + + // Status now reports drift: checkout behind the lock. + stdout = mustRun(t, workDir, "status") + if !strings.Contains(stdout, "drift") { + t.Errorf("expected drift after lock update, got: %s", stdout) + } + + // --sync moves the checkout to the new pin. + mustRun(t, workDir, "lock", "update", "--sync", "--quiet") + if got := gitIn(t, checkoutDir, "rev-parse", "HEAD"); got != newSHA { + t.Errorf("lock update --sync left checkout at %s, want %s", got, newSHA) + } +} + +func TestE2E_LockAdopt(t *testing.T) { + requireGit(t) + workDir := t.TempDir() + setupSyncedWorkspace(t, workDir, "repo") + checkoutDir := filepath.Join(workDir, "repo") + + // Commit locally without pushing. + commitFileIn(t, checkoutDir, "local.txt", "local content") + localSHA := gitIn(t, checkoutDir, "rev-parse", "HEAD") + + // Unpushed HEAD is refused without --force. + _, stderr, err := runCommand(t, workDir, "lock", "adopt") + if err == nil { + t.Error("expected 'lock adopt' to refuse an unpushed HEAD") + } + if !strings.Contains(stderr, "--force") { + t.Errorf("expected refusal to mention --force, got: %s", stderr) + } + if lockFileContains(t, workDir, localSHA) { + t.Error("lock file must not change when adopt is refused") + } + + // --force pins the local HEAD, with a warning. + _, stderr, err = runCommand(t, workDir, "lock", "adopt", "--force") + if err != nil { + t.Fatalf("lock adopt --force failed: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stderr, "warning") { + t.Errorf("expected a warning when force-adopting an unpushed HEAD, got: %s", stderr) + } + if !lockFileContains(t, workDir, localSHA) { + t.Error("expected lock file to contain the local HEAD SHA") + } + + // Status agrees the checkout matches the lock. + stdout := mustRun(t, workDir, "status") + if !strings.Contains(stdout, "locked") { + t.Errorf("expected locked status after adopt, got: %s", stdout) + } +} diff --git a/cmd/harbormaster/lock.go b/cmd/harbormaster/lock.go new file mode 100644 index 0000000..92635f4 --- /dev/null +++ b/cmd/harbormaster/lock.go @@ -0,0 +1,222 @@ +package main + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + "github.com/tierone/harbormaster/pkg/manager" +) + +var ( + lockUpdateProject string + lockUpdateTag string + lockUpdateDryRun bool + lockUpdateSync bool + + lockAdoptProject string + lockAdoptTag string + lockAdoptDryRun bool + lockAdoptForce bool +) + +var lockCmd = &cobra.Command{ + Use: "lock", + Short: "Manage the lock file", + Long: `Manage the lock file without touching repository checkouts. + +'hm lock update' pins each repository to the latest commit on its +configured branch (resolved against the remote). 'hm lock adopt' pins each +repository to its current local HEAD. Both only rewrite .harbormaster.lock; +use 'hm sync' (or 'hm lock update --sync') to move the checkouts.`, +} + +var lockUpdateCmd = &cobra.Command{ + Use: "update [repository...]", + Short: "Pin repositories to the latest commit on their configured branch", + Long: `Resolve each repository's configured branch against its remote and +record the tip commit in the lock file. Local checkouts are not modified. + +Repositories whose config pins a commit or tag are skipped: their lock +entry follows the config, not a moving branch. + +Positional repository names, --project, and --tag can be combined; the +union of all matching repositories is updated.`, + RunE: runLockUpdate, +} + +var lockAdoptCmd = &cobra.Command{ + Use: "adopt [repository...]", + Short: "Pin repositories to their current local HEAD", + Long: `Record each repository's current local HEAD commit in the lock +file. The checkout is not modified. + +A HEAD that has not been pushed to origin cannot be reproduced by +'hm sync --locked' on another machine, so adopting it is refused unless +--force is given. + +Positional repository names, --project, and --tag can be combined; the +union of all matching repositories is updated.`, + RunE: runLockAdopt, +} + +func init() { + lockUpdateCmd.Flags().StringVarP(&lockUpdateProject, "project", "p", "", "update repositories in project") + lockUpdateCmd.Flags().StringVarP(&lockUpdateTag, "tag", "t", "", "update repositories with tag") + lockUpdateCmd.Flags().BoolVar(&lockUpdateDryRun, "dry-run", false, "show what would change without writing the lock file") + lockUpdateCmd.Flags().BoolVar(&lockUpdateSync, "sync", false, "also check out the new pins after updating the lock file") + + lockAdoptCmd.Flags().StringVarP(&lockAdoptProject, "project", "p", "", "adopt repositories in project") + lockAdoptCmd.Flags().StringVarP(&lockAdoptTag, "tag", "t", "", "adopt repositories with tag") + lockAdoptCmd.Flags().BoolVar(&lockAdoptDryRun, "dry-run", false, "show what would change without writing the lock file") + lockAdoptCmd.Flags().BoolVar(&lockAdoptForce, "force", false, "adopt a HEAD that has not been pushed to origin") + + lockCmd.AddCommand(lockUpdateCmd) + lockCmd.AddCommand(lockAdoptCmd) + rootCmd.AddCommand(lockCmd) +} + +func runLockUpdate(cmd *cobra.Command, args []string) error { + filter := buildFilter(args, lockUpdateProject, lockUpdateTag) + mgr := manager.NewRepositoryManager(cfg, manager.WithLockFile(lf)) + + changes, err := mgr.UpdateLockToRemote(filter) + if err != nil { + return err + } + + printLockChanges(changes, "remote tip", lockUpdateDryRun) + + if lockUpdateDryRun { + return lockChangesError(changes) + } + if err := saveLockFile(); err != nil { + return fmt.Errorf("failed to save lock file: %w", err) + } + if err := lockChangesError(changes); err != nil { + return err + } + + if lockUpdateSync { + return syncLockedRepos(filter) + } + return nil +} + +func runLockAdopt(cmd *cobra.Command, args []string) error { + filter := buildFilter(args, lockAdoptProject, lockAdoptTag) + mgr := manager.NewRepositoryManager(cfg, manager.WithLockFile(lf)) + + changes, err := mgr.UpdateLockToLocal(filter, lockAdoptForce) + if err != nil { + return err + } + + printLockChanges(changes, "local HEAD", lockAdoptDryRun) + + if lockAdoptDryRun { + return lockChangesError(changes) + } + if err := saveLockFile(); err != nil { + return fmt.Errorf("failed to save lock file: %w", err) + } + return lockChangesError(changes) +} + +// printLockChanges renders the per-repository results. Failures and +// warnings go to stderr so machine consumers of stdout never see them and +// --quiet cannot hide them; the table itself honors --quiet. +func printLockChanges(changes []manager.LockChange, source string, dryRun bool) { + for _, c := range changes { + if c.Error != nil { + fmt.Fprintf(os.Stderr, " %s: %v\n", c.Name, c.Error) + } + if c.Warning != "" { + fmt.Fprintf(os.Stderr, " %s: warning: %s\n", c.Name, c.Warning) + } + } + + if quiet { + return + } + + if dryRun { + fmt.Println("Dry run — lock file will not be modified:") + fmt.Println() + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + for _, c := range changes { + switch { + case c.Error != nil: + // Already reported on stderr. + case c.Skipped: + _, _ = fmt.Fprintf(w, " %s - skipped: %s\n", c.Name, c.SkipReason) + case !c.Changed: + _, _ = fmt.Fprintf(w, " %s %s already up to date\n", c.Name, shortSHA(c.NewSHA)) + default: + old := shortSHA(c.OldSHA) + if c.OldSHA == "" { + old = "-" + } + note := source + if c.Dirty { + note += ", dirty worktree" + } + _, _ = fmt.Fprintf(w, " %s %s -> %s %s\n", c.Name, old, shortSHA(c.NewSHA), note) + } + } + _ = w.Flush() +} + +// lockChangesError returns an error summarizing failed repositories, or nil. +func lockChangesError(changes []manager.LockChange) error { + failed := 0 + for _, c := range changes { + if c.Error != nil { + failed++ + } + } + if failed > 0 { + return fmt.Errorf("%d of %d repositories failed", failed, len(changes)) + } + return nil +} + +// syncLockedRepos syncs the filtered repositories to their (newly updated) +// lock entries. +func syncLockedRepos(filter manager.Filter) error { + interactive := !quiet && isatty.IsTerminal(os.Stdout.Fd()) + mgr := manager.NewRepositoryManager(cfg, + manager.WithLockFile(lf), + manager.WithLocked(true), + manager.WithInteractive(interactive), + ) + + // As in 'hm sync', --quiet silences the progress UI on stdout but never + // the failure report on stderr. + if quiet { + if devnull, devErr := os.OpenFile(os.DevNull, os.O_WRONLY, 0); devErr == nil { + orig := os.Stdout + os.Stdout = devnull + defer func() { + os.Stdout = orig + _ = devnull.Close() + }() + } + } + + result, err := mgr.Sync(filter) + if err != nil { + return err + } + if result.HasFailures() { + for _, f := range result.FailedResults() { + fmt.Fprintf(os.Stderr, " %s: %v\n", f.RepoName, f.Error) + } + return fmt.Errorf("%d of %d repositories failed to sync", result.FailureCount, result.TotalRepos) + } + return nil +} diff --git a/pkg/downloader/git.go b/pkg/downloader/git.go index f7997b2..443df82 100644 --- a/pkg/downloader/git.go +++ b/pkg/downloader/git.go @@ -548,6 +548,45 @@ func GetCurrentBranch(path string) (string, error) { return branch, nil } +// ResolveRemoteRef returns the commit SHA that ref points at on the remote, +// without needing a local clone. ref should be a fully-qualified ref such as +// "refs/heads/main". The URL may carry credentials; they are scrubbed from +// any error message. +func ResolveRemoteRef(url, ref string) (string, error) { + if err := validateRef(ref); err != nil { + return "", err + } + cmd := exec.Command("git", "ls-remote", url, ref) + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to resolve %s on remote: %w", ref, err) + } + // Output is " " per matching line; we queried one exact ref. + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == ref { + return fields[0], nil + } + } + return "", fmt.Errorf("ref %s not found on remote %s", ref, scrubCredentials(url)) +} + +// RemoteContains reports whether any remote-tracking ref in the repository +// at path contains the given commit — i.e. whether the commit has been +// published to a remote this clone knows about. +func RemoteContains(path, commit string) (bool, error) { + if err := validateRef(commit); err != nil { + return false, err + } + cmd := exec.Command("git", "branch", "-r", "--contains", commit) + cmd.Dir = path + output, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("failed to check remote containment: %w", err) + } + return strings.TrimSpace(string(output)) != "", nil +} + // Exists returns true if the destination exists. func Exists(path string) bool { _, err := os.Stat(path) diff --git a/pkg/downloader/git_remote_test.go b/pkg/downloader/git_remote_test.go new file mode 100644 index 0000000..87b8b1d --- /dev/null +++ b/pkg/downloader/git_remote_test.go @@ -0,0 +1,84 @@ +package downloader + +import ( + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestResolveRemoteRef(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + src := setupTestGitRepo(t) + head := gitRun(t, src, "rev-parse", "HEAD") + branch := gitRun(t, src, "rev-parse", "--abbrev-ref", "HEAD") + + sha, err := ResolveRemoteRef(src, "refs/heads/"+branch) + if err != nil { + t.Fatalf("ResolveRemoteRef failed: %v", err) + } + if sha != head { + t.Errorf("expected %s, got %s", head, sha) + } + + // An absent ref is an error, not an empty result. + _, err = ResolveRemoteRef(src, "refs/heads/does-not-exist") + if err == nil { + t.Error("expected error for missing ref") + } else if !strings.Contains(err.Error(), "not found") { + t.Errorf("expected 'not found' error, got: %v", err) + } + + // Ref names that look like flags are rejected before git runs. + if _, err := ResolveRemoteRef(src, "--upload-pack=evil"); err == nil { + t.Error("expected error for option-like ref") + } +} + +func TestRemoteContains(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + src := setupTestGitRepo(t) + clone := filepath.Join(t.TempDir(), "clone") + gitRun(t, t.TempDir(), "clone", src, clone) + gitRun(t, clone, "config", "user.email", "test@test.com") + gitRun(t, clone, "config", "user.name", "Test User") + gitRun(t, clone, "config", "commit.gpgSign", "false") + + // The cloned HEAD is on origin's branch tip: contained. + head := gitRun(t, clone, "rev-parse", "HEAD") + ok, err := RemoteContains(clone, head) + if err != nil { + t.Fatalf("RemoteContains failed: %v", err) + } + if !ok { + t.Error("expected cloned HEAD to be contained in an origin ref") + } + + // A local-only commit is not contained. + local := addCommit(t, clone, "local.txt", "local", "local commit") + ok, err = RemoteContains(clone, local) + if err != nil { + t.Fatalf("RemoteContains failed: %v", err) + } + if ok { + t.Error("expected local-only commit to not be contained in any origin ref") + } + + // After pushing, the same commit is contained (push updates the local + // remote-tracking ref). + gitRun(t, src, "config", "receive.denyCurrentBranch", "ignore") + gitRun(t, clone, "push", "origin", "HEAD:refs/heads/published") + ok, err = RemoteContains(clone, local) + if err != nil { + t.Fatalf("RemoteContains failed: %v", err) + } + if !ok { + t.Error("expected pushed commit to be contained in an origin ref") + } +} diff --git a/pkg/downloader/git_test.go b/pkg/downloader/git_test.go index 5fa35e1..226addd 100644 --- a/pkg/downloader/git_test.go +++ b/pkg/downloader/git_test.go @@ -60,6 +60,10 @@ func setupTestGitRepo(t *testing.T) string { {"git", "init"}, {"git", "config", "user.email", "test@test.com"}, {"git", "config", "user.name", "Test User"}, + // Tests must not depend on the developer's global git config; a + // global commit.gpgSign=true would otherwise block test commits on + // a pinentry prompt. + {"git", "config", "commit.gpgSign", "false"}, } for _, cmd := range commands { diff --git a/pkg/downloader/git_work_test.go b/pkg/downloader/git_work_test.go index 8012287..1d22df8 100644 --- a/pkg/downloader/git_work_test.go +++ b/pkg/downloader/git_work_test.go @@ -22,6 +22,7 @@ func setupRepoWithBareOrigin(t *testing.T) (workDir, bareDir string) { gitRun(t, t.TempDir(), "clone", bareDir, workDir) gitRun(t, workDir, "config", "user.email", "test@test.com") gitRun(t, workDir, "config", "user.name", "Test User") + gitRun(t, workDir, "config", "commit.gpgSign", "false") return workDir, bareDir } diff --git a/pkg/lockfile/lockfile.go b/pkg/lockfile/lockfile.go index a2275a5..1c533b9 100644 --- a/pkg/lockfile/lockfile.go +++ b/pkg/lockfile/lockfile.go @@ -87,7 +87,7 @@ func (lf *LockFile) Save(path string) error { // Write header comment header := "# Harbormaster Lock File\n" + "# DO NOT EDIT - This file is auto-generated\n" + - "# Use 'hm sync' to update\n\n" + "# Use 'hm lock update', 'hm lock adopt', or 'hm sync' to update\n\n" if _, err := f.WriteString(header); err != nil { return err } diff --git a/pkg/manager/lock_ops.go b/pkg/manager/lock_ops.go new file mode 100644 index 0000000..2f2c0b3 --- /dev/null +++ b/pkg/manager/lock_ops.go @@ -0,0 +1,154 @@ +package manager + +import ( + "fmt" + + "github.com/tierone/harbormaster/pkg/config" + "github.com/tierone/harbormaster/pkg/downloader" + "github.com/tierone/harbormaster/pkg/lockfile" +) + +// LockChange describes the result of a lock-file operation on one repository. +type LockChange struct { + Name string + OldSHA string // previously locked SHA, "" if there was no entry + NewSHA string // SHA now recorded in the lock file + Changed bool // NewSHA differs from OldSHA + Skipped bool // the repository is not eligible (see SkipReason) + SkipReason string + Warning string // non-fatal caveat (e.g. unpushed commit adopted with force) + Dirty bool // worktree had uncommitted changes when the SHA was read + Error error // fatal for this repository; no lock entry was written +} + +// UpdateLockToRemote updates lock entries to the latest commit on each +// repository's configured branch, resolving refs against the remote with +// 'git ls-remote'. Local checkouts are not touched. Repositories whose +// config pins a tag or commit are skipped: their lock entry follows the +// config, not a moving branch. HTTP repositories are skipped as well. +func (m *RepositoryManager) UpdateLockToRemote(filter Filter) ([]LockChange, error) { + if m.lockFile == nil { + return nil, fmt.Errorf("no lock file available") + } + + repos, err := m.getRepositories(filter) + if err != nil { + return nil, err + } + + changes := make([]LockChange, 0, len(repos)) + for _, repo := range repos { + change := LockChange{Name: repo.Name} + if entry, ok := m.lockFile.Get(repo.Name); ok { + change.OldSHA = entry.ResolvedSHA + } + + switch { + case repo.Type != config.RepoTypeGit: + change.Skipped = true + change.SkipReason = "not a git repository" + case repo.Commit != "" || repo.Tag != "": + change.Skipped = true + change.SkipReason = "config pins a commit/tag; edit the config to change it" + default: + branch := repo.Branch + if branch == "" { + branch = m.config.General.DefaultBranch + } + sha, err := downloader.ResolveRemoteRef(repo.URL, "refs/heads/"+branch) + if err != nil { + change.Error = err + break + } + change.NewSHA = sha + change.Changed = sha != change.OldSHA + m.updateLockEntry(&repo, sha) + } + + changes = append(changes, change) + } + return changes, nil +} + +// UpdateLockToLocal updates lock entries to each repository's current local +// HEAD ("adopt"). The local checkout is not modified. A HEAD that is not +// contained in any remote-tracking ref cannot be reproduced by 'hm sync +// --locked' on another machine, so adopting it is refused unless force is +// true; with force the entry is written and the change carries a warning. +func (m *RepositoryManager) UpdateLockToLocal(filter Filter, force bool) ([]LockChange, error) { + if m.lockFile == nil { + return nil, fmt.Errorf("no lock file available") + } + + repos, err := m.getRepositories(filter) + if err != nil { + return nil, err + } + + changes := make([]LockChange, 0, len(repos)) + for _, repo := range repos { + change := LockChange{Name: repo.Name} + if entry, ok := m.lockFile.Get(repo.Name); ok { + change.OldSHA = entry.ResolvedSHA + } + + if repo.Type != config.RepoTypeGit { + change.Skipped = true + change.SkipReason = "not a git repository" + changes = append(changes, change) + continue + } + + repoPath := m.getRepoPath(&repo) + if !downloader.Exists(repoPath) { + change.Error = fmt.Errorf("repository not found at %s (run 'hm sync' first)", repoPath) + changes = append(changes, change) + continue + } + + sha, err := downloader.GetHeadSHA(repoPath) + if err != nil { + change.Error = err + changes = append(changes, change) + continue + } + change.NewSHA = sha + + if dirty, err := downloader.IsDirty(repoPath); err == nil { + change.Dirty = dirty + } + + published, err := downloader.RemoteContains(repoPath, sha) + if err != nil { + change.Error = err + changes = append(changes, change) + continue + } + if !published { + msg := "local HEAD is not contained in any origin ref; 'hm sync --locked' cannot reproduce it elsewhere — push it first, or use --force to pin anyway" + if !force { + change.Error = fmt.Errorf("%s", msg) + changes = append(changes, change) + continue + } + change.Warning = msg + } + + change.Changed = sha != change.OldSHA + m.updateLockEntry(&repo, sha) + changes = append(changes, change) + } + return changes, nil +} + +// updateLockEntry writes a lock entry for the repository pinned to sha. +func (m *RepositoryManager) updateLockEntry(repo *config.Repository, sha string) { + requestedRef := repo.GetEffectiveRef(m.config.General.DefaultBranch) + entry := lockfile.NewEntry( + repo.URL, + string(repo.Type), + requestedRef, + sha, + ) + m.lockFile.Update(repo.Name, entry) +} diff --git a/pkg/manager/lock_ops_test.go b/pkg/manager/lock_ops_test.go new file mode 100644 index 0000000..3cfca7d --- /dev/null +++ b/pkg/manager/lock_ops_test.go @@ -0,0 +1,227 @@ +package manager + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/tierone/harbormaster/pkg/config" + "github.com/tierone/harbormaster/pkg/lockfile" +) + +// testRepoBranch returns the initial branch of a freshly created test repo. +func testRepoBranch(t *testing.T, dir string) string { + t.Helper() + return gitCmd(t, dir, "rev-parse", "--abbrev-ref", "HEAD") +} + +func findChange(changes []LockChange, name string) *LockChange { + for i := range changes { + if changes[i].Name == name { + return &changes[i] + } + } + return nil +} + +func TestUpdateLockToRemote(t *testing.T) { + requireGit(t) + + src := setupTestGitRepo(t, "src") + branch := testRepoBranch(t, src) + cfg := newSyncConfig(t, + config.Repository{Name: "repo", URL: src, Type: config.RepoTypeGit, Path: "repo", Branch: branch}, + ) + repoPath := filepath.Join(cfg.General.WorkDir, "repo") + + lf := lockfile.New() + mgr := NewRepositoryManager(cfg, WithLockFile(lf), WithInteractive(false)) + if _, err := mgr.Sync(Filter{All: true}); err != nil { + t.Fatalf("initial Sync failed: %v", err) + } + lockedSHA, _ := lf.GetResolvedSHA("repo") + + // The source moves ahead. + newTip := addCommit(t, src, "new.txt", "new content") + + changes, err := mgr.UpdateLockToRemote(Filter{All: true}) + if err != nil { + t.Fatalf("UpdateLockToRemote failed: %v", err) + } + c := findChange(changes, "repo") + if c == nil { + t.Fatal("expected a change record for repo") + } + if c.Error != nil || c.Skipped { + t.Fatalf("unexpected change state: %+v", c) + } + if !c.Changed || c.OldSHA != lockedSHA || c.NewSHA != newTip { + t.Errorf("expected %s -> %s, got %+v", lockedSHA, newTip, c) + } + + // Lock file updated... + if sha, _ := lf.GetResolvedSHA("repo"); sha != newTip { + t.Errorf("lock file has %s, want %s", sha, newTip) + } + // ...but the checkout must be untouched. + if got := headSHA(t, repoPath); got != lockedSHA { + t.Errorf("checkout moved to %s, want untouched %s", got, lockedSHA) + } + + // A second run reports no change. + changes, err = mgr.UpdateLockToRemote(Filter{All: true}) + if err != nil { + t.Fatalf("second UpdateLockToRemote failed: %v", err) + } + if c := findChange(changes, "repo"); c.Changed { + t.Errorf("expected unchanged on second run, got %+v", c) + } +} + +func TestUpdateLockToRemote_SkipsPinnedAndHTTP(t *testing.T) { + requireGit(t) + + src := setupTestGitRepo(t, "src") + pinned := headSHA(t, src) + cfg := newSyncConfig(t, + config.Repository{Name: "pinned", URL: src, Type: config.RepoTypeGit, Path: "pinned", Commit: pinned}, + config.Repository{Name: "asset", URL: "https://example.com/a.bin", Type: config.RepoTypeHTTP, Path: "asset"}, + ) + + lf := lockfile.New() + mgr := NewRepositoryManager(cfg, WithLockFile(lf), WithInteractive(false)) + + changes, err := mgr.UpdateLockToRemote(Filter{All: true}) + if err != nil { + t.Fatalf("UpdateLockToRemote failed: %v", err) + } + for _, name := range []string{"pinned", "asset"} { + c := findChange(changes, name) + if c == nil || !c.Skipped { + t.Errorf("expected %s to be skipped, got %+v", name, c) + } + } + if lf.Len() != 0 { + t.Errorf("no lock entries should be written, got %d", lf.Len()) + } +} + +func TestUpdateLockToRemote_UnknownBranchFails(t *testing.T) { + requireGit(t) + + src := setupTestGitRepo(t, "src") + cfg := newSyncConfig(t, + config.Repository{Name: "repo", URL: src, Type: config.RepoTypeGit, Path: "repo", Branch: "no-such-branch"}, + ) + + lf := lockfile.New() + mgr := NewRepositoryManager(cfg, WithLockFile(lf), WithInteractive(false)) + + changes, err := mgr.UpdateLockToRemote(Filter{All: true}) + if err != nil { + t.Fatalf("UpdateLockToRemote failed: %v", err) + } + c := findChange(changes, "repo") + if c == nil || c.Error == nil { + t.Fatalf("expected an error for the unknown branch, got %+v", c) + } + if !strings.Contains(c.Error.Error(), "not found") { + t.Errorf("expected 'not found' error, got: %v", c.Error) + } + if lf.Has("repo") { + t.Error("must not write a lock entry for a failed repo") + } +} + +func TestUpdateLockToLocal(t *testing.T) { + requireGit(t) + + src := setupTestGitRepo(t, "src") + branch := testRepoBranch(t, src) + cfg := newSyncConfig(t, + config.Repository{Name: "repo", URL: src, Type: config.RepoTypeGit, Path: "repo", Branch: branch}, + ) + repoPath := filepath.Join(cfg.General.WorkDir, "repo") + + lf := lockfile.New() + mgr := NewRepositoryManager(cfg, WithLockFile(lf), WithInteractive(false)) + if _, err := mgr.Sync(Filter{All: true}); err != nil { + t.Fatalf("initial Sync failed: %v", err) + } + lockedSHA, _ := lf.GetResolvedSHA("repo") + + // Commit locally without pushing. + gitCmd(t, repoPath, "config", "user.email", "test@test.com") + gitCmd(t, repoPath, "config", "user.name", "Test User") + gitCmd(t, repoPath, "config", "commit.gpgSign", "false") + localSHA := addCommit(t, repoPath, "local.txt", "local content") + + // Unpushed HEAD: refused without force. + changes, err := mgr.UpdateLockToLocal(Filter{All: true}, false) + if err != nil { + t.Fatalf("UpdateLockToLocal failed: %v", err) + } + c := findChange(changes, "repo") + if c == nil || c.Error == nil { + t.Fatalf("expected refusal for unpushed HEAD, got %+v", c) + } + if !strings.Contains(c.Error.Error(), "--force") { + t.Errorf("expected error to mention --force, got: %v", c.Error) + } + if sha, _ := lf.GetResolvedSHA("repo"); sha != lockedSHA { + t.Error("lock file must not change when adopt is refused") + } + + // With force the entry is written and carries a warning. + changes, err = mgr.UpdateLockToLocal(Filter{All: true}, true) + if err != nil { + t.Fatalf("UpdateLockToLocal --force failed: %v", err) + } + c = findChange(changes, "repo") + if c.Error != nil || !c.Changed || c.NewSHA != localSHA || c.OldSHA != lockedSHA { + t.Errorf("unexpected forced adopt result: %+v", c) + } + if c.Warning == "" { + t.Error("expected a warning when adopting an unpushed HEAD") + } + if sha, _ := lf.GetResolvedSHA("repo"); sha != localSHA { + t.Errorf("lock file has %s, want %s", sha, localSHA) + } + + // After pushing, adopt succeeds without force and without warning. + gitCmd(t, src, "config", "receive.denyCurrentBranch", "ignore") + gitCmd(t, repoPath, "push", "origin", "HEAD:refs/heads/"+branch) + changes, err = mgr.UpdateLockToLocal(Filter{All: true}, false) + if err != nil { + t.Fatalf("UpdateLockToLocal after push failed: %v", err) + } + c = findChange(changes, "repo") + if c.Error != nil || c.Warning != "" { + t.Errorf("expected clean adopt after push, got %+v", c) + } + if c.Changed { + t.Error("expected unchanged: lock already at local HEAD") + } +} + +func TestUpdateLockToLocal_MissingRepo(t *testing.T) { + requireGit(t) + + src := setupTestGitRepo(t, "src") + cfg := newSyncConfig(t, + config.Repository{Name: "repo", URL: src, Type: config.RepoTypeGit, Path: "repo"}, + ) + + mgr := NewRepositoryManager(cfg, WithLockFile(lockfile.New()), WithInteractive(false)) + changes, err := mgr.UpdateLockToLocal(Filter{All: true}, false) + if err != nil { + t.Fatalf("UpdateLockToLocal failed: %v", err) + } + c := findChange(changes, "repo") + if c == nil || c.Error == nil { + t.Fatal("expected an error for a missing checkout") + } + if !strings.Contains(c.Error.Error(), "hm sync") { + t.Errorf("expected error to point at 'hm sync', got: %v", c.Error) + } +} diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index bbf0f3a..fbbf552 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -333,14 +333,7 @@ func (m *RepositoryManager) updateLockFile(results []types.OperationResult) { continue } - requestedRef := repo.GetEffectiveRef(m.config.General.DefaultBranch) - entry := lockfile.NewEntry( - repo.URL, - string(repo.Type), - requestedRef, - result.CommitSHA, - ) - m.lockFile.Update(result.RepoName, entry) + m.updateLockEntry(repo, result.CommitSHA) } } diff --git a/pkg/manager/manager_test.go b/pkg/manager/manager_test.go index a2403a7..0ef807e 100644 --- a/pkg/manager/manager_test.go +++ b/pkg/manager/manager_test.go @@ -25,6 +25,10 @@ func setupTestGitRepo(t *testing.T, name string) string { {"git", "init"}, {"git", "config", "user.email", "test@test.com"}, {"git", "config", "user.name", "Test User"}, + // Tests must not depend on the developer's global git config; a + // global commit.gpgSign=true would otherwise block test commits on + // a pinentry prompt. + {"git", "config", "commit.gpgSign", "false"}, } for _, cmd := range commands { diff --git a/pkg/manager/work_ops_test.go b/pkg/manager/work_ops_test.go index 85ee838..9f4caaa 100644 --- a/pkg/manager/work_ops_test.go +++ b/pkg/manager/work_ops_test.go @@ -29,6 +29,7 @@ func setupWorkWorkspace(t *testing.T, n int) (*config.Config, *RepositoryManager gitCmd(t, repoDir, "init") gitCmd(t, repoDir, "config", "user.email", "test@test.com") gitCmd(t, repoDir, "config", "user.name", "Test User") + gitCmd(t, repoDir, "config", "commit.gpgSign", "false") addCommit(t, repoDir, "README.md", "# "+name) repos = append(repos, config.Repository{