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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
101 changes: 100 additions & 1 deletion cmd/harbormaster/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
222 changes: 222 additions & 0 deletions cmd/harbormaster/lock.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading