From 39a7d49c254dc4688b3b76730cba2fd1f0d22da4 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Fri, 27 Mar 2026 08:48:23 -0700 Subject: [PATCH] feat: add workspace-centric bdev cli (#585) * fix: clean-up bdev * feat: add workspace-centric bdev cli * fix: address review comments for 0326-bdev_cli_redesign * fix: address review feedback for PR #585 * fix: address review feedback for PR #585 --- packages/browseros/tools/bdev/.gitignore | 5 - packages/browseros/tools/bdev/Makefile | 34 +- packages/browseros/tools/bdev/cmd/abort.go | 32 ++ packages/browseros/tools/bdev/cmd/add.go | 42 ++ packages/browseros/tools/bdev/cmd/apply.go | 65 +++ packages/browseros/tools/bdev/cmd/clone.go | 151 ------- packages/browseros/tools/bdev/cmd/common.go | 49 +++ packages/browseros/tools/bdev/cmd/continue.go | 39 ++ packages/browseros/tools/bdev/cmd/diff.go | 139 ++---- packages/browseros/tools/bdev/cmd/extract.go | 73 ++++ packages/browseros/tools/bdev/cmd/init.go | 115 ----- packages/browseros/tools/bdev/cmd/list.go | 49 +++ packages/browseros/tools/bdev/cmd/publish.go | 41 ++ packages/browseros/tools/bdev/cmd/pull.go | 142 ------ packages/browseros/tools/bdev/cmd/push.go | 238 ---------- packages/browseros/tools/bdev/cmd/remove.go | 33 ++ packages/browseros/tools/bdev/cmd/root.go | 118 ++++- packages/browseros/tools/bdev/cmd/skip.go | 33 ++ packages/browseros/tools/bdev/cmd/status.go | 121 ++---- packages/browseros/tools/bdev/cmd/sync.go | 58 +++ .../browseros/tools/bdev/cmd/sync_args.go | 44 -- .../tools/bdev/cmd/sync_args_test.go | 59 --- .../browseros/tools/bdev/e2e/main_test.go | 40 -- .../tools/bdev/e2e/operations_test.go | 244 ----------- packages/browseros/tools/bdev/go.mod | 5 +- packages/browseros/tools/bdev/go.sum | 6 +- .../browseros/tools/bdev/internal/app/app.go | 67 +++ .../tools/bdev/internal/config/base.go | 57 --- .../tools/bdev/internal/config/config.go | 41 -- .../tools/bdev/internal/config/resolve.go | 101 ----- .../tools/bdev/internal/config/state.go | 50 --- .../tools/bdev/internal/engine/apply.go | 410 ++++++++++++++++++ .../tools/bdev/internal/engine/clone.go | 120 ----- .../tools/bdev/internal/engine/clone_test.go | 50 --- .../tools/bdev/internal/engine/engine_test.go | 220 ++++++++++ .../tools/bdev/internal/engine/extract.go | 119 +++++ .../tools/bdev/internal/engine/publish.go | 45 ++ .../tools/bdev/internal/engine/pull.go | 345 --------------- .../tools/bdev/internal/engine/pull_test.go | 156 ------- .../tools/bdev/internal/engine/push.go | 147 ------- .../tools/bdev/internal/engine/push_test.go | 43 -- .../tools/bdev/internal/engine/status.go | 115 +++-- .../tools/bdev/internal/engine/status_test.go | 23 - .../tools/bdev/internal/engine/sync.go | 130 ++++++ .../tools/bdev/internal/git/apply.go | 123 ------ .../tools/bdev/internal/git/checkout.go | 20 - .../browseros/tools/bdev/internal/git/diff.go | 179 -------- .../browseros/tools/bdev/internal/git/exec.go | 41 -- .../browseros/tools/bdev/internal/git/git.go | 387 +++++++++++++++++ .../tools/bdev/internal/git/git_test.go | 28 ++ .../browseros/tools/bdev/internal/git/log.go | 32 -- .../tools/bdev/internal/git/remote.go | 91 ---- .../tools/bdev/internal/git/remote_test.go | 101 ----- .../browseros/tools/bdev/internal/git/rev.go | 27 -- .../tools/bdev/internal/git/status.go | 61 --- .../tools/bdev/internal/log/activity.go | 130 ------ .../tools/bdev/internal/patch/compare.go | 107 +++-- .../tools/bdev/internal/patch/parser.go | 138 +++--- .../tools/bdev/internal/patch/patch_test.go | 146 +++++++ .../tools/bdev/internal/patch/reader.go | 159 ------- .../tools/bdev/internal/patch/reader_test.go | 77 ---- .../tools/bdev/internal/patch/repo.go | 164 +++++++ .../tools/bdev/internal/patch/stale.go | 75 ---- .../tools/bdev/internal/patch/types.go | 127 +++--- .../tools/bdev/internal/patch/workspace.go | 215 +++++++++ .../tools/bdev/internal/patch/writer.go | 119 ----- .../tools/bdev/internal/patch/writer_test.go | 87 ---- .../tools/bdev/internal/repo/repo.go | 61 +++ .../tools/bdev/internal/resolve/resolve.go | 98 +++++ .../bdev/internal/resolve/resolve_test.go | 59 +++ .../tools/bdev/internal/ui/activity.go | 58 --- .../tools/bdev/internal/ui/output.go | 46 -- .../tools/bdev/internal/ui/report.go | 41 -- .../tools/bdev/internal/ui/styles.go | 31 -- .../browseros/tools/bdev/internal/ui/ui.go | 84 ++++ .../tools/bdev/internal/workspace/config.go | 63 +++ .../tools/bdev/internal/workspace/detect.go | 49 +++ .../tools/bdev/internal/workspace/registry.go | 121 ++++++ .../tools/bdev/internal/workspace/state.go | 70 +++ .../bdev/internal/workspace/workspace_test.go | 55 +++ packages/browseros/tools/bdev/main.go | 15 +- 81 files changed, 3554 insertions(+), 4145 deletions(-) delete mode 100644 packages/browseros/tools/bdev/.gitignore create mode 100644 packages/browseros/tools/bdev/cmd/abort.go create mode 100644 packages/browseros/tools/bdev/cmd/add.go create mode 100644 packages/browseros/tools/bdev/cmd/apply.go delete mode 100644 packages/browseros/tools/bdev/cmd/clone.go create mode 100644 packages/browseros/tools/bdev/cmd/common.go create mode 100644 packages/browseros/tools/bdev/cmd/continue.go create mode 100644 packages/browseros/tools/bdev/cmd/extract.go delete mode 100644 packages/browseros/tools/bdev/cmd/init.go create mode 100644 packages/browseros/tools/bdev/cmd/list.go create mode 100644 packages/browseros/tools/bdev/cmd/publish.go delete mode 100644 packages/browseros/tools/bdev/cmd/pull.go delete mode 100644 packages/browseros/tools/bdev/cmd/push.go create mode 100644 packages/browseros/tools/bdev/cmd/remove.go create mode 100644 packages/browseros/tools/bdev/cmd/skip.go create mode 100644 packages/browseros/tools/bdev/cmd/sync.go delete mode 100644 packages/browseros/tools/bdev/cmd/sync_args.go delete mode 100644 packages/browseros/tools/bdev/cmd/sync_args_test.go delete mode 100644 packages/browseros/tools/bdev/e2e/main_test.go delete mode 100644 packages/browseros/tools/bdev/e2e/operations_test.go create mode 100644 packages/browseros/tools/bdev/internal/app/app.go delete mode 100644 packages/browseros/tools/bdev/internal/config/base.go delete mode 100644 packages/browseros/tools/bdev/internal/config/config.go delete mode 100644 packages/browseros/tools/bdev/internal/config/resolve.go delete mode 100644 packages/browseros/tools/bdev/internal/config/state.go create mode 100644 packages/browseros/tools/bdev/internal/engine/apply.go delete mode 100644 packages/browseros/tools/bdev/internal/engine/clone.go delete mode 100644 packages/browseros/tools/bdev/internal/engine/clone_test.go create mode 100644 packages/browseros/tools/bdev/internal/engine/engine_test.go create mode 100644 packages/browseros/tools/bdev/internal/engine/extract.go create mode 100644 packages/browseros/tools/bdev/internal/engine/publish.go delete mode 100644 packages/browseros/tools/bdev/internal/engine/pull.go delete mode 100644 packages/browseros/tools/bdev/internal/engine/pull_test.go delete mode 100644 packages/browseros/tools/bdev/internal/engine/push.go delete mode 100644 packages/browseros/tools/bdev/internal/engine/push_test.go delete mode 100644 packages/browseros/tools/bdev/internal/engine/status_test.go create mode 100644 packages/browseros/tools/bdev/internal/engine/sync.go delete mode 100644 packages/browseros/tools/bdev/internal/git/apply.go delete mode 100644 packages/browseros/tools/bdev/internal/git/checkout.go delete mode 100644 packages/browseros/tools/bdev/internal/git/diff.go delete mode 100644 packages/browseros/tools/bdev/internal/git/exec.go create mode 100644 packages/browseros/tools/bdev/internal/git/git.go create mode 100644 packages/browseros/tools/bdev/internal/git/git_test.go delete mode 100644 packages/browseros/tools/bdev/internal/git/log.go delete mode 100644 packages/browseros/tools/bdev/internal/git/remote.go delete mode 100644 packages/browseros/tools/bdev/internal/git/remote_test.go delete mode 100644 packages/browseros/tools/bdev/internal/git/rev.go delete mode 100644 packages/browseros/tools/bdev/internal/git/status.go delete mode 100644 packages/browseros/tools/bdev/internal/log/activity.go create mode 100644 packages/browseros/tools/bdev/internal/patch/patch_test.go delete mode 100644 packages/browseros/tools/bdev/internal/patch/reader.go delete mode 100644 packages/browseros/tools/bdev/internal/patch/reader_test.go create mode 100644 packages/browseros/tools/bdev/internal/patch/repo.go delete mode 100644 packages/browseros/tools/bdev/internal/patch/stale.go create mode 100644 packages/browseros/tools/bdev/internal/patch/workspace.go delete mode 100644 packages/browseros/tools/bdev/internal/patch/writer.go delete mode 100644 packages/browseros/tools/bdev/internal/patch/writer_test.go create mode 100644 packages/browseros/tools/bdev/internal/repo/repo.go create mode 100644 packages/browseros/tools/bdev/internal/resolve/resolve.go create mode 100644 packages/browseros/tools/bdev/internal/resolve/resolve_test.go delete mode 100644 packages/browseros/tools/bdev/internal/ui/activity.go delete mode 100644 packages/browseros/tools/bdev/internal/ui/output.go delete mode 100644 packages/browseros/tools/bdev/internal/ui/report.go delete mode 100644 packages/browseros/tools/bdev/internal/ui/styles.go create mode 100644 packages/browseros/tools/bdev/internal/ui/ui.go create mode 100644 packages/browseros/tools/bdev/internal/workspace/config.go create mode 100644 packages/browseros/tools/bdev/internal/workspace/detect.go create mode 100644 packages/browseros/tools/bdev/internal/workspace/registry.go create mode 100644 packages/browseros/tools/bdev/internal/workspace/state.go create mode 100644 packages/browseros/tools/bdev/internal/workspace/workspace_test.go diff --git a/packages/browseros/tools/bdev/.gitignore b/packages/browseros/tools/bdev/.gitignore deleted file mode 100644 index 785464764..000000000 --- a/packages/browseros/tools/bdev/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -bros -bros-linux-amd64 -bros-linux-arm64 -bros-darwin-amd64 -bros-darwin-arm64 diff --git a/packages/browseros/tools/bdev/Makefile b/packages/browseros/tools/bdev/Makefile index 91c6369a2..ba36e14cc 100644 --- a/packages/browseros/tools/bdev/Makefile +++ b/packages/browseros/tools/bdev/Makefile @@ -1,27 +1,27 @@ BINARY := bdev -VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -LDFLAGS := -ldflags "-X main.version=$(VERSION)" +PREFIX ?= /usr/local/bin +VERSION ?= dev -.PHONY: build install clean test +.PHONY: build install clean test fmt build: - go build $(LDFLAGS) -o $(BINARY) . + go build -ldflags "-X github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/cmd.Version=$(VERSION)" -o $(BINARY) . -install: - go install $(LDFLAGS) . - -clean: - rm -f $(BINARY) +install: build + mkdir -p $(PREFIX) + cp $(BINARY) $(PREFIX)/$(BINARY) +ifneq ($(shell uname -s),Darwin) + @echo "Skipping codesign on non-macOS host" +else + codesign --force --sign - $(PREFIX)/$(BINARY) +endif + @echo "Installed $(BINARY) to $(PREFIX)/$(BINARY)" test: go test ./... -build-linux: - GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY)-linux-amd64 . +fmt: + gofmt -w $$(find . -name '*.go' -not -path './vendor/*') -build-linux-arm: - GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o $(BINARY)-linux-arm64 . - -build-darwin: - GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY)-darwin-amd64 . - GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BINARY)-darwin-arm64 . +clean: + rm -f $(BINARY) diff --git a/packages/browseros/tools/bdev/cmd/abort.go b/packages/browseros/tools/bdev/cmd/abort.go new file mode 100644 index 000000000..81ca9bd98 --- /dev/null +++ b/packages/browseros/tools/bdev/cmd/abort.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "fmt" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui" + "github.com/spf13/cobra" +) + +func init() { + command := &cobra.Command{ + Use: "abort", + Annotations: map[string]string{"group": "Conflict:"}, + Short: "Abort conflict resolution and roll the pending files back", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := resolve.FindActive(appState.Registry, appState.CWD) + if err != nil { + return err + } + if err := engine.Abort(cmd.Context(), ws); err != nil { + return err + } + return renderResult(map[string]any{"workspace": ws.Name, "aborted": true}, func() { + fmt.Println(ui.Warning(fmt.Sprintf("Aborted conflict resolution for %s", ws.Name))) + }) + }, + } + rootCmd.AddCommand(command) +} diff --git a/packages/browseros/tools/bdev/cmd/add.go b/packages/browseros/tools/bdev/cmd/add.go new file mode 100644 index 000000000..c15da4cde --- /dev/null +++ b/packages/browseros/tools/bdev/cmd/add.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui" + "github.com/spf13/cobra" +) + +func init() { + var patchesRepo string + command := &cobra.Command{ + Use: "add ", + Aliases: []string{"register"}, + Annotations: map[string]string{"group": "Workspace:"}, + Short: "Register a Chromium checkout as a workspace", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if err := ensureRepoConfigured(patchesRepo); err != nil { + return err + } + entry, err := appState.Registry.Add(args[0], args[1]) + if err != nil { + return err + } + if err := appState.Save(); err != nil { + return err + } + return renderResult(map[string]any{ + "workspace": entry, + "patches_repo": appState.Config.PatchesRepo, + }, func() { + fmt.Println(ui.Success("Registered workspace")) + fmt.Printf("%s %s\n", ui.Muted("name:"), entry.Name) + fmt.Printf("%s %s\n", ui.Muted("path:"), entry.Path) + fmt.Printf("%s %s\n", ui.Muted("repo:"), appState.Config.PatchesRepo) + }) + }, + } + command.Flags().StringVar(&patchesRepo, "patches-repo", "", "Path to packages/browseros") + rootCmd.AddCommand(command) +} diff --git a/packages/browseros/tools/bdev/cmd/apply.go b/packages/browseros/tools/bdev/cmd/apply.go new file mode 100644 index 000000000..f5da10ce0 --- /dev/null +++ b/packages/browseros/tools/bdev/cmd/apply.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "fmt" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui" + "github.com/spf13/cobra" +) + +func init() { + var src string + var reset bool + var changed string + var rangeEnd string + command := &cobra.Command{ + Use: "apply [workspace] [-- files...]", + Annotations: map[string]string{"group": "Core:"}, + Short: "Apply repo patches to a workspace", + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + positional, filters := splitWorkspaceAndFilters(cmd, args) + if len(positional) > 1 { + return fmt.Errorf("expected at most one workspace name") + } + ws, err := resolveWorkspace(positional, src) + if err != nil { + return err + } + info, err := repoInfo() + if err != nil { + return err + } + result, err := engine.Apply(cmd.Context(), engine.ApplyOptions{ + Workspace: ws, + Repo: info, + Reset: reset, + ChangedRef: changed, + RangeEnd: rangeEnd, + Filters: filters, + }) + if err != nil { + return err + } + return renderResult(result, func() { + fmt.Println(ui.Title(fmt.Sprintf("Applied patches to %s", ws.Name))) + fmt.Printf("%s %s\n", ui.Muted("mode:"), result.Mode) + fmt.Printf("%s %d\n", ui.Muted("applied:"), len(result.Applied)) + fmt.Printf("%s %d\n", ui.Muted("orphaned:"), len(result.Orphaned)) + if len(result.Conflicts) > 0 { + fmt.Println(ui.Warning("Conflicts detected")) + for _, conflict := range result.Conflicts { + fmt.Printf(" %s\n", conflict.ChromiumPath) + } + fmt.Println(ui.Hint(`Run "bdev continue" after fixing the current conflict.`)) + } + }) + }, + } + command.Flags().StringVar(&src, "src", "", "Chromium checkout path to operate on directly") + command.Flags().BoolVar(&reset, "reset", false, "Reset patched files to BASE_COMMIT before applying") + command.Flags().StringVar(&changed, "changed", "", "Apply only patches changed in the given repo commit") + command.Flags().StringVar(&rangeEnd, "range-end", "", "End revision when using --changed as a range start") + rootCmd.AddCommand(command) +} diff --git a/packages/browseros/tools/bdev/cmd/clone.go b/packages/browseros/tools/bdev/cmd/clone.go deleted file mode 100644 index 24b767764..000000000 --- a/packages/browseros/tools/bdev/cmd/clone.go +++ /dev/null @@ -1,151 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - "time" - - "bdev/internal/config" - "bdev/internal/engine" - "bdev/internal/git" - "bdev/internal/log" - "bdev/internal/ui" - - "github.com/spf13/cobra" -) - -var cloneCmd = &cobra.Command{ - Use: "clone", - Short: "Fresh-apply all patches (for CI/new checkouts)", - Long: `Apply all patches from the patches repository onto the current -Chromium checkout. Used for CI builds and new checkout setup. -Unlike pull, clone does not compare existing state — it applies everything.`, - RunE: runClone, -} - -var ( - clonePatchesRepo string - cloneVerifyBase bool - cloneClean bool - cloneDryRun bool - cloneName string -) - -func init() { - cloneCmd.Flags().StringVar(&clonePatchesRepo, "patches-repo", "", "path to BrowserOS packages directory") - cloneCmd.Flags().BoolVar(&cloneVerifyBase, "verify-base", false, "fail if HEAD != BASE_COMMIT") - cloneCmd.Flags().BoolVar(&cloneClean, "clean", false, "reset all modified files to BASE before applying") - cloneCmd.Flags().BoolVar(&cloneDryRun, "dry-run", false, "show what would be applied") - cloneCmd.Flags().StringVar(&cloneName, "name", "", "checkout name (default: directory name)") - rootCmd.AddCommand(cloneCmd) -} - -func runClone(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("getting cwd: %w", err) - } - - // Try loading existing context, or create one from flags - ctx, err := config.LoadContext() - if err != nil { - // No existing .bros/ — need --patches-repo - if clonePatchesRepo == "" { - return fmt.Errorf("no .bros/ found and --patches-repo not specified") - } - - patchesRepo, err := filepath.Abs(clonePatchesRepo) - if err != nil { - return fmt.Errorf("resolving patches repo: %w", err) - } - - baseCommit, err := config.ReadBaseCommit(patchesRepo) - if err != nil { - return err - } - - name := cloneName - if name == "" { - name = filepath.Base(cwd) - } - - brosDir := filepath.Join(cwd, config.BrosDirName) - cfg := &config.Config{ - Name: name, - PatchesRepo: patchesRepo, - } - if !cloneDryRun { - if err := config.WriteConfig(brosDir, cfg); err != nil { - return err - } - _ = os.MkdirAll(filepath.Join(brosDir, "logs"), 0o755) - } - - chromiumVersion, _ := config.ReadChromiumVersion(patchesRepo) - ctx = &config.Context{ - Config: cfg, - State: &config.State{}, - ChromiumDir: cwd, - BrosDir: brosDir, - PatchesRepo: patchesRepo, - PatchesDir: filepath.Join(patchesRepo, "chromium_patches"), - BaseCommit: baseCommit, - ChromiumVersion: chromiumVersion, - } - } - - if cloneDryRun { - fmt.Println(ui.MutedStyle.Render("dry run — no files will be modified")) - fmt.Println() - } - - opts := engine.CloneOpts{ - VerifyBase: cloneVerifyBase, - Clean: cloneClean, - DryRun: cloneDryRun, - } - - result, err := engine.Clone(ctx, opts) - if err != nil { - return err - } - - // Reuse pull rendering - fmt.Println(ui.TitleStyle.Render("bdev clone")) - fmt.Println() - fmt.Printf(" %s %d patches applied\n", - ui.SuccessStyle.Render("+"), len(result.Applied)) - if len(result.Conflicts) > 0 { - fmt.Printf(" %s %d conflicts\n", - ui.ErrorStyle.Render("x"), len(result.Conflicts)) - } - if len(result.Deleted) > 0 { - fmt.Printf(" %s %d files deleted\n", - ui.DeletedPrefix, len(result.Deleted)) - } - - if len(result.Conflicts) > 0 { - fmt.Print(ui.RenderConflictReport(result.Conflicts)) - } - - if !cloneDryRun { - repoRev, _ := git.HeadRev(ctx.PatchesRepo) - ctx.State.LastPull = &config.SyncEvent{ - PatchesRepoRev: repoRev, - BaseCommit: ctx.BaseCommit, - Timestamp: time.Now(), - FileCount: len(result.Applied) + len(result.Deleted), - } - _ = config.WriteState(ctx.BrosDir, ctx.State) - - logger := log.New(ctx.BrosDir) - _ = logger.LogClone(ctx.BaseCommit, result) - } - - if len(result.Conflicts) > 0 { - return fmt.Errorf("%d conflicts — see above for details", len(result.Conflicts)) - } - - return nil -} diff --git a/packages/browseros/tools/bdev/cmd/common.go b/packages/browseros/tools/bdev/cmd/common.go new file mode 100644 index 000000000..f44f10191 --- /dev/null +++ b/packages/browseros/tools/bdev/cmd/common.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace" + "github.com/spf13/cobra" +) + +func repoInfo() (*repo.Info, error) { + return appState.RepoInfo() +} + +func resolveWorkspace(positional []string, src string) (workspace.Entry, error) { + name := "" + if len(positional) > 0 { + name = positional[0] + } + return appState.ResolveWorkspace(name, src) +} + +func splitWorkspaceAndFilters(cmd *cobra.Command, args []string) ([]string, []string) { + atDash := cmd.ArgsLenAtDash() + if atDash == -1 { + return args, nil + } + return args[:atDash], args[atDash:] +} + +func ensureRepoConfigured(override string) error { + if override == "" && appState.Config.PatchesRepo != "" { + return nil + } + root := override + if root == "" { + discovered, err := repo.Discover(appState.CWD) + if err != nil { + return fmt.Errorf(`unable to discover patches repo; pass --patches-repo or run from packages/browseros`) + } + root = discovered + } + info, err := repo.Load(root) + if err != nil { + return err + } + appState.Config.PatchesRepo = info.Root + return nil +} diff --git a/packages/browseros/tools/bdev/cmd/continue.go b/packages/browseros/tools/bdev/cmd/continue.go new file mode 100644 index 000000000..0bef56553 --- /dev/null +++ b/packages/browseros/tools/bdev/cmd/continue.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui" + "github.com/spf13/cobra" +) + +func init() { + command := &cobra.Command{ + Use: "continue", + Annotations: map[string]string{"group": "Conflict:"}, + Short: "Advance to the next conflict after fixing the current one", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := resolve.FindActive(appState.Registry, appState.CWD) + if err != nil { + return err + } + result, err := engine.Continue(cmd.Context(), ws) + if err != nil { + return err + } + return renderResult(result, func() { + fmt.Println(ui.Success(fmt.Sprintf("Advanced conflict resolution for %s", ws.Name))) + if len(result.Conflicts) > 0 { + fmt.Println(ui.Warning("Next conflict")) + for _, conflict := range result.Conflicts { + fmt.Printf(" %s\n", conflict.ChromiumPath) + } + } + }) + }, + } + rootCmd.AddCommand(command) +} diff --git a/packages/browseros/tools/bdev/cmd/diff.go b/packages/browseros/tools/bdev/cmd/diff.go index fb735dbe2..296efb68e 100644 --- a/packages/browseros/tools/bdev/cmd/diff.go +++ b/packages/browseros/tools/bdev/cmd/diff.go @@ -2,113 +2,52 @@ package cmd import ( "fmt" + "strings" - "bdev/internal/config" - "bdev/internal/git" - "bdev/internal/patch" - "bdev/internal/ui" - + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui" "github.com/spf13/cobra" ) -var diffCmd = &cobra.Command{ - Use: "diff", - Short: "Preview what push or pull would do", - RunE: runDiff, -} - -var diffDirection string - func init() { - diffCmd.Flags().StringVar(&diffDirection, "direction", "push", "\"push\" or \"pull\"") - rootCmd.AddCommand(diffCmd) + var src string + command := &cobra.Command{ + Use: "diff [workspace]", + Annotations: map[string]string{"group": "Core:"}, + Short: "Preview patch differences for a workspace", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := resolveWorkspace(args, src) + if err != nil { + return err + } + info, err := repoInfo() + if err != nil { + return err + } + status, err := engine.InspectWorkspace(cmd.Context(), ws, info) + if err != nil { + return err + } + return renderResult(status, func() { + fmt.Println(ui.Title(fmt.Sprintf("%s patch diff", ws.Name))) + printGroup("Needs apply", status.NeedsApply) + printGroup("Needs update", status.NeedsUpdate) + printGroup("Orphaned", status.Orphaned) + }) + }, + } + command.Flags().StringVar(&src, "src", "", "Chromium checkout path to operate on directly") + rootCmd.AddCommand(command) } -func runDiff(cmd *cobra.Command, args []string) error { - ctx, err := config.LoadContext() - if err != nil { - return err +func printGroup(title string, items []string) { + if len(items) == 0 { + fmt.Printf("%s %s\n", ui.Muted(title+":"), ui.Muted("none")) + return } - - switch diffDirection { - case "push": - return diffPush(ctx) - case "pull": - return diffPull(ctx) - default: - return fmt.Errorf("invalid direction %q — use \"push\" or \"pull\"", diffDirection) + fmt.Printf("%s\n", ui.Header(title+":")) + for _, item := range items { + fmt.Printf(" %s\n", strings.TrimSpace(item)) } } - -func diffPush(ctx *config.Context) error { - nameStatus, err := git.DiffNameStatus(ctx.ChromiumDir, ctx.BaseCommit) - if err != nil { - return err - } - - if len(nameStatus) == 0 { - fmt.Println(ui.MutedStyle.Render("No local changes to push.")) - return nil - } - - fmt.Println(ui.TitleStyle.Render("bdev diff --direction push")) - fmt.Println() - - for path, op := range nameStatus { - prefix := ui.ModifiedPrefix - switch op { - case patch.OpAdded: - prefix = ui.AddedPrefix - case patch.OpDeleted: - prefix = ui.DeletedPrefix - } - fmt.Printf(" %s %s\n", prefix, path) - } - fmt.Println() - fmt.Println(ui.MutedStyle.Render(fmt.Sprintf("%d files would be pushed", len(nameStatus)))) - - return nil -} - -func diffPull(ctx *config.Context) error { - repoPatchSet, err := patch.ReadPatchSet(ctx.PatchesDir) - if err != nil { - return err - } - - diffOutput, err := git.DiffFull(ctx.ChromiumDir, ctx.BaseCommit) - if err != nil { - return err - } - - localPatchSet, err := patch.ParseUnifiedDiff(diffOutput) - if err != nil { - return err - } - - delta := patch.Compare(localPatchSet, repoPatchSet) - - total := len(delta.NeedsUpdate) + len(delta.NeedsApply) - if total == 0 && len(delta.Deleted) == 0 { - fmt.Println(ui.MutedStyle.Render("Already up to date.")) - return nil - } - - fmt.Println(ui.TitleStyle.Render("bdev diff --direction pull")) - fmt.Println() - - for _, f := range delta.NeedsUpdate { - fmt.Printf(" %s %s %s\n", ui.ModifiedPrefix, f, ui.MutedStyle.Render("(update)")) - } - for _, f := range delta.NeedsApply { - fmt.Printf(" %s %s %s\n", ui.AddedPrefix, f, ui.MutedStyle.Render("(new)")) - } - for _, f := range delta.Deleted { - fmt.Printf(" %s %s %s\n", ui.DeletedPrefix, f, ui.MutedStyle.Render("(delete)")) - } - - fmt.Println() - fmt.Println(ui.MutedStyle.Render(fmt.Sprintf("%d files would be changed", total+len(delta.Deleted)))) - - return nil -} diff --git a/packages/browseros/tools/bdev/cmd/extract.go b/packages/browseros/tools/bdev/cmd/extract.go new file mode 100644 index 000000000..f20e99c3f --- /dev/null +++ b/packages/browseros/tools/bdev/cmd/extract.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "fmt" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui" + "github.com/spf13/cobra" +) + +func init() { + var src string + var commit string + var rangeMode bool + var squash bool + var base string + command := &cobra.Command{ + Use: "extract [workspace] [--range ] [-- files...]", + Annotations: map[string]string{"group": "Core:"}, + Short: "Extract workspace changes back to chromium_patches", + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + positional, filters := splitWorkspaceAndFilters(cmd, args) + workspaceArgs := positional + rangeStart := "" + rangeEnd := "" + if rangeMode { + if len(positional) < 2 || len(positional) > 3 { + return fmt.Errorf(`range mode expects "bdev extract [workspace] --range "`) + } + rangeStart = positional[len(positional)-2] + rangeEnd = positional[len(positional)-1] + workspaceArgs = positional[:len(positional)-2] + } + if len(workspaceArgs) > 1 { + return fmt.Errorf("expected at most one workspace name") + } + ws, err := resolveWorkspace(workspaceArgs, src) + if err != nil { + return err + } + info, err := repoInfo() + if err != nil { + return err + } + result, err := engine.Extract(cmd.Context(), engine.ExtractOptions{ + Workspace: ws, + Repo: info, + Commit: commit, + RangeStart: rangeStart, + RangeEnd: rangeEnd, + Squash: squash, + Base: base, + Filters: filters, + }) + if err != nil { + return err + } + return renderResult(result, func() { + fmt.Println(ui.Title(fmt.Sprintf("Extracted patches from %s", ws.Name))) + fmt.Printf("%s %s\n", ui.Muted("mode:"), result.Mode) + fmt.Printf("%s %d\n", ui.Muted("written:"), len(result.Written)) + fmt.Printf("%s %d\n", ui.Muted("deleted:"), len(result.Deleted)) + }) + }, + } + command.Flags().StringVar(&src, "src", "", "Chromium checkout path to operate on directly") + command.Flags().StringVar(&commit, "commit", "", "Extract from a single commit") + command.Flags().BoolVar(&rangeMode, "range", false, "Extract from a commit range") + command.Flags().BoolVar(&squash, "squash", false, "Squash a range into a cumulative diff") + command.Flags().StringVar(&base, "base", "", "Override BASE_COMMIT for extraction") + rootCmd.AddCommand(command) +} diff --git a/packages/browseros/tools/bdev/cmd/init.go b/packages/browseros/tools/bdev/cmd/init.go deleted file mode 100644 index 81fcef7d0..000000000 --- a/packages/browseros/tools/bdev/cmd/init.go +++ /dev/null @@ -1,115 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - - "bdev/internal/config" - "bdev/internal/git" - "bdev/internal/ui" - - "github.com/spf13/cobra" -) - -var initCmd = &cobra.Command{ - Use: "init", - Short: "Initialize a Chromium checkout for bdev", - Long: "Sets up a .bros/ directory in the current Chromium checkout,\nlinking it to a BrowserOS patches repository.", - RunE: runInit, -} - -var ( - initPatchesRepo string - initName string -) - -func init() { - initCmd.Flags().StringVar(&initPatchesRepo, "patches-repo", "", "path to BrowserOS packages directory (required)") - initCmd.Flags().StringVar(&initName, "name", "", "human name for this checkout (default: directory name)") - _ = initCmd.MarkFlagRequired("patches-repo") - rootCmd.AddCommand(initCmd) -} - -func runInit(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("getting cwd: %w", err) - } - - if !config.LooksLikeChromium(cwd) { - return fmt.Errorf("current directory does not look like a Chromium checkout (missing chrome/, base/, or .git/)") - } - - brosDir := filepath.Join(cwd, config.BrosDirName) - if _, err := os.Stat(filepath.Join(brosDir, "config.yaml")); err == nil { - return fmt.Errorf(".bros/config.yaml already exists — checkout already initialized") - } - - patchesRepo, err := filepath.Abs(initPatchesRepo) - if err != nil { - return fmt.Errorf("resolving patches repo path: %w", err) - } - - patchesDir := filepath.Join(patchesRepo, "chromium_patches") - if _, err := os.Stat(patchesDir); err != nil { - return fmt.Errorf("chromium_patches/ not found in %s", patchesRepo) - } - - baseCommit, err := config.ReadBaseCommit(patchesRepo) - if err != nil { - return err - } - - if !git.CommitExists(cwd, baseCommit) { - return fmt.Errorf("BASE_COMMIT %s not found in this checkout's git history", baseCommit) - } - - name := initName - if name == "" { - name = filepath.Base(cwd) - } - - cfg := &config.Config{ - Name: name, - PatchesRepo: patchesRepo, - } - if err := config.WriteConfig(brosDir, cfg); err != nil { - return err - } - - // Create logs directory - if err := os.MkdirAll(filepath.Join(brosDir, "logs"), 0o755); err != nil { - return fmt.Errorf("creating logs directory: %w", err) - } - - chromiumVersion, _ := config.ReadChromiumVersion(patchesRepo) - - // Count existing patches - patchCount := 0 - _ = filepath.Walk(patchesDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - if !info.IsDir() { - patchCount++ - } - return nil - }) - - fmt.Println(ui.TitleStyle.Render("bdev init")) - fmt.Println() - fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Checkout:"), ui.ValueStyle.Render(name)) - fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Directory:"), cwd) - fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Patches repo:"), patchesRepo) - fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Base commit:"), baseCommit[:min(12, len(baseCommit))]) - if chromiumVersion != "" { - fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Chromium:"), chromiumVersion) - } - fmt.Printf(" %s %d files\n", ui.LabelStyle.Render("Patches:"), patchCount) - fmt.Println() - fmt.Println(ui.SuccessStyle.Render("Initialized .bros/config.yaml")) - fmt.Println(ui.MutedStyle.Render("Run 'bdev pull' to apply patches, or 'bdev push' to extract.")) - - return nil -} diff --git a/packages/browseros/tools/bdev/cmd/list.go b/packages/browseros/tools/bdev/cmd/list.go new file mode 100644 index 000000000..f015bda88 --- /dev/null +++ b/packages/browseros/tools/bdev/cmd/list.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui" + "github.com/spf13/cobra" +) + +func init() { + command := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Annotations: map[string]string{"group": "Workspace:"}, + Short: "List registered workspaces and their sync state", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(appState.Registry.Workspaces) == 0 { + return renderResult(map[string]any{"workspaces": []any{}}, func() { + fmt.Println("No workspaces registered. Run `bdev add `.") + }) + } + info, err := repoInfo() + if err != nil { + return err + } + rows := make([][]string, 0, len(appState.Registry.Workspaces)) + statuses := make([]*engine.WorkspaceStatus, 0, len(appState.Registry.Workspaces)) + for _, ws := range appState.Registry.Workspaces { + status, err := engine.InspectWorkspace(cmd.Context(), ws, info) + if err != nil { + return err + } + statuses = append(statuses, status) + rows = append(rows, []string{ + ws.Name, + status.SyncState, + fmt.Sprintf("%d/%d/%d", len(status.UpToDate), len(status.NeedsUpdate), len(status.Orphaned)), + ws.Path, + }) + } + return renderResult(map[string]any{"workspaces": statuses}, func() { + fmt.Println(ui.RenderTable([]string{"NAME", "STATE", "PATCHES", "PATH"}, rows)) + }) + }, + } + rootCmd.AddCommand(command) +} diff --git a/packages/browseros/tools/bdev/cmd/publish.go b/packages/browseros/tools/bdev/cmd/publish.go new file mode 100644 index 000000000..4f8c12091 --- /dev/null +++ b/packages/browseros/tools/bdev/cmd/publish.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui" + "github.com/spf13/cobra" +) + +func init() { + var message string + command := &cobra.Command{ + Use: "publish [remote]", + Annotations: map[string]string{"group": "Remote:"}, + Short: "Commit and push chromium_patches to a remote", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + info, err := repoInfo() + if err != nil { + return err + } + remote := "origin" + if len(args) == 1 { + remote = args[0] + } + result, err := engine.Publish(cmd.Context(), info, remote, message) + if err != nil { + return err + } + return renderResult(result, func() { + fmt.Println(ui.Success("Published chromium_patches")) + fmt.Printf("%s %s\n", ui.Muted("remote:"), result.Remote) + fmt.Printf("%s %s\n", ui.Muted("branch:"), result.Branch) + fmt.Printf("%s %s\n", ui.Muted("message:"), result.Message) + }) + }, + } + command.Flags().StringVarP(&message, "message", "m", "", "Commit message for the patch publish commit") + rootCmd.AddCommand(command) +} diff --git a/packages/browseros/tools/bdev/cmd/pull.go b/packages/browseros/tools/bdev/cmd/pull.go deleted file mode 100644 index cbf2084cc..000000000 --- a/packages/browseros/tools/bdev/cmd/pull.go +++ /dev/null @@ -1,142 +0,0 @@ -package cmd - -import ( - "fmt" - "time" - - "bdev/internal/config" - "bdev/internal/engine" - "bdev/internal/git" - "bdev/internal/log" - "bdev/internal/ui" - - "github.com/spf13/cobra" -) - -var pullCmd = &cobra.Command{ - Use: "pull [remote] [-- file1 file2 ...]", - Short: "Pull patches from repo to checkout", - Long: `Apply patches from the patches repository to the current Chromium -checkout. Use an optional remote (for example: 'bdev pull origin') -to fetch/rebase the patches repo before applying changes locally.`, - RunE: runPull, -} - -var ( - pullDryRun bool - pullRemote string - pullNoSync bool - pullRebase bool - pullKeepLocalOnly bool -) - -func init() { - pullCmd.Flags().BoolVar(&pullDryRun, "dry-run", false, "show what would change") - pullCmd.Flags().StringVar(&pullRemote, "remote", "", "patches repo remote to sync before pull") - pullCmd.Flags().BoolVar(&pullNoSync, "no-sync", false, "skip syncing patches repo from remote") - pullCmd.Flags().BoolVar(&pullRebase, "rebase", true, "use git pull --rebase when syncing remote") - pullCmd.Flags().BoolVar(&pullKeepLocalOnly, "keep-local-only", true, "keep local-only checkout changes that are not in patches repo") - rootCmd.AddCommand(pullCmd) -} - -func runPull(cmd *cobra.Command, args []string) error { - ctx, err := config.LoadContext() - if err != nil { - return err - } - - activity := ui.NewActivity(verbose) - remote, files, err := resolveRemoteAndFiles(ctx.PatchesRepo, args, pullRemote) - if err != nil { - return err - } - - shouldSync := remote != "" && !pullNoSync && !pullDryRun - if shouldSync { - dirty, err := git.IsDirty(ctx.PatchesRepo) - if err != nil { - return err - } - if dirty { - return fmt.Errorf("patches repo has local changes; commit/stash before syncing remote %q", remote) - } - - activity.Step("syncing patches repo from remote %q", remote) - beforeRev, _ := git.HeadRev(ctx.PatchesRepo) - - if err := git.Fetch(ctx.PatchesRepo, remote); err != nil { - return err - } - - branch, detached, err := git.CurrentBranch(ctx.PatchesRepo) - if err != nil { - return err - } - if detached { - activity.Warn("patches repo is in detached HEAD; fetched remote but skipped pull/rebase") - } else { - if err := git.Pull(ctx.PatchesRepo, remote, branch, pullRebase); err != nil { - return err - } - } - - afterRev, _ := git.HeadRev(ctx.PatchesRepo) - if beforeRev != "" && afterRev != "" && beforeRev != afterRev { - activity.Success("patches repo advanced %s -> %s", shortRev(beforeRev), shortRev(afterRev)) - } else { - activity.Info("patches repo already up to date") - } - - ctx, err = config.LoadContext() - if err != nil { - return err - } - } else if remote != "" && pullDryRun { - activity.Info("dry run enabled — skipping remote sync") - } else if remote != "" && pullNoSync { - activity.Info("remote %q provided, but sync is disabled via --no-sync", remote) - } - - opts := engine.PullOpts{ - DryRun: pullDryRun, - Files: files, - KeepLocalOnly: pullKeepLocalOnly, - } - - if pullDryRun { - activity.Info("dry run enabled — no files will be modified") - activity.Divider() - } - - activity.Step("computing patch delta and applying updates") - result, err := engine.Pull(ctx, opts) - if err != nil { - return err - } - - fmt.Print(ui.RenderPullResult(result)) - - if len(result.Conflicts) > 0 { - fmt.Print(ui.RenderConflictReport(result.Conflicts)) - } - - if !pullDryRun { - repoRev, _ := git.HeadRev(ctx.PatchesRepo) - ctx.State.LastPull = &config.SyncEvent{ - PatchesRepoRev: repoRev, - BaseCommit: ctx.BaseCommit, - Timestamp: time.Now(), - FileCount: len(result.Applied) + len(result.Deleted) + len(result.Reverted) + len(result.LocalOnly) + len(result.Skipped), - } - _ = config.WriteState(ctx.BrosDir, ctx.State) - - logger := log.New(ctx.BrosDir) - _ = logger.LogPull(ctx.BaseCommit, repoRev, result) - } - - if len(result.Conflicts) > 0 { - return fmt.Errorf("%d conflicts — see above for details", len(result.Conflicts)) - } - - return nil -} diff --git a/packages/browseros/tools/bdev/cmd/push.go b/packages/browseros/tools/bdev/cmd/push.go deleted file mode 100644 index e1f5eed31..000000000 --- a/packages/browseros/tools/bdev/cmd/push.go +++ /dev/null @@ -1,238 +0,0 @@ -package cmd - -import ( - "fmt" - "time" - - "bdev/internal/config" - "bdev/internal/engine" - "bdev/internal/git" - "bdev/internal/log" - "bdev/internal/patch" - "bdev/internal/ui" - - "github.com/spf13/cobra" -) - -var pushCmd = &cobra.Command{ - Use: "push [remote] [-- file1 file2 ...]", - Short: "Push local changes to patches repo", - Long: `Extract diffs from the current Chromium checkout and write them -to the patches repository. When a remote is provided (for example: -'bdev push origin'), bdev commits patch changes and pushes upstream.`, - RunE: runPush, -} - -var ( - pushDryRun bool - pushRemote string - pushNoSync bool - pushRebase bool - pushMessage string -) - -func init() { - pushCmd.Flags().BoolVar(&pushDryRun, "dry-run", false, "show what would be pushed") - pushCmd.Flags().StringVar(&pushRemote, "remote", "", "patches repo remote to publish to") - pushCmd.Flags().BoolVar(&pushNoSync, "no-sync", false, "skip syncing patches repo from remote before publish") - pushCmd.Flags().BoolVar(&pushRebase, "rebase", true, "use git pull --rebase when syncing before publish") - pushCmd.Flags().StringVarP(&pushMessage, "message", "m", "", "commit message when publishing to remote") - rootCmd.AddCommand(pushCmd) -} - -func runPush(cmd *cobra.Command, args []string) error { - ctx, err := config.LoadContext() - if err != nil { - return err - } - - activity := ui.NewActivity(verbose) - remote, files, err := resolveRemoteAndFiles(ctx.PatchesRepo, args, pushRemote) - if err != nil { - return err - } - - shouldPublish := remote != "" && !pushDryRun - if shouldPublish { - dirty, err := git.IsDirty(ctx.PatchesRepo) - if err != nil { - return err - } - if dirty { - return fmt.Errorf("patches repo has local changes; commit/stash before publishing to remote %q", remote) - } - } - - if shouldPublish && !pushNoSync { - if err := syncPatchesRepo(activity, ctx.PatchesRepo, remote, pushRebase); err != nil { - return err - } - } - - if remote != "" && pushDryRun { - activity.Info("dry run enabled — skipping remote sync and publish") - } - - opts := engine.PushOpts{ - DryRun: pushDryRun, - Files: files, - } - - if pushDryRun { - activity.Info("dry run enabled — no patch files will be written") - activity.Divider() - } - - activity.Step("extracting checkout changes into patches") - result, err := engine.Push(ctx, opts) - if err != nil { - return err - } - - renderPushResult(result, pushDryRun) - - if !pushDryRun { - if remote != "" { - if err := publishPatchChanges(activity, ctx, remote, result, pushMessage); err != nil { - return err - } - } - - // Update state - repoRev, _ := git.HeadRev(ctx.PatchesRepo) - ctx.State.LastPush = &config.SyncEvent{ - PatchesRepoRev: repoRev, - Timestamp: time.Now(), - FileCount: result.Total() + len(result.Stale), - } - _ = config.WriteState(ctx.BrosDir, ctx.State) - - // Activity log - logger := log.New(ctx.BrosDir) - _ = logger.LogPush(ctx.BaseCommit, result) - } - - return nil -} - -func syncPatchesRepo(activity *ui.Activity, patchesRepo, remote string, rebase bool) error { - activity.Step("syncing patches repo from remote %q", remote) - beforeRev, _ := git.HeadRev(patchesRepo) - - if err := git.Fetch(patchesRepo, remote); err != nil { - return err - } - - branch, detached, err := git.CurrentBranch(patchesRepo) - if err != nil { - return err - } - if detached { - return fmt.Errorf("patches repo is in detached HEAD; cannot sync for publish") - } - - if err := git.Pull(patchesRepo, remote, branch, rebase); err != nil { - return err - } - - afterRev, _ := git.HeadRev(patchesRepo) - if beforeRev != "" && afterRev != "" && beforeRev != afterRev { - activity.Success("patches repo advanced %s -> %s", shortRev(beforeRev), shortRev(afterRev)) - } else { - activity.Info("patches repo already up to date") - } - return nil -} - -func publishPatchChanges( - activity *ui.Activity, - ctx *config.Context, - remote string, - result *patch.PushResult, - commitMessage string, -) error { - dirty, err := git.IsDirty(ctx.PatchesRepo, "chromium_patches") - if err != nil { - return err - } - if !dirty { - activity.Info("no patch repository changes to commit") - return nil - } - - branch, detached, err := git.CurrentBranch(ctx.PatchesRepo) - if err != nil { - return err - } - if detached { - return fmt.Errorf("patches repo is in detached HEAD; cannot publish") - } - - message := commitMessage - if message == "" { - message = fmt.Sprintf( - "bdev push: %s (%d modified, %d added, %d deleted, %d stale)", - ctx.Config.Name, - len(result.Modified), - len(result.Added), - len(result.Deleted), - len(result.Stale), - ) - } - - activity.Step("committing patch changes to %s", branch) - if err := git.Add(ctx.PatchesRepo, "chromium_patches"); err != nil { - return err - } - if err := git.Commit(ctx.PatchesRepo, message); err != nil { - return err - } - activity.Success("created patch commit") - - activity.Step("pushing patch commit to %s/%s", remote, branch) - if err := git.Push(ctx.PatchesRepo, remote, branch); err != nil { - return err - } - activity.Success("remote publish complete") - - return nil -} - -func renderPushResult(r *patch.PushResult, dryRun bool) { - if r.Total() == 0 && len(r.Stale) == 0 { - fmt.Println(ui.MutedStyle.Render("Nothing to push — checkout matches patches repo.")) - return - } - - verb := "Pushed" - if dryRun { - verb = "Would push" - } - - fmt.Println(ui.TitleStyle.Render("bdev push")) - fmt.Println() - - for _, f := range r.Added { - fmt.Printf(" %s %s\n", ui.AddedPrefix, f) - } - for _, f := range r.Modified { - fmt.Printf(" %s %s\n", ui.ModifiedPrefix, f) - } - for _, f := range r.Deleted { - fmt.Printf(" %s %s\n", ui.DeletedPrefix, f) - } - for _, f := range r.Stale { - fmt.Printf(" %s %s\n", ui.SkippedPrefix, ui.MutedStyle.Render(f+" (stale, removed)")) - } - - fmt.Println() - summary := fmt.Sprintf("%s %d patches", verb, r.Total()) - detail := fmt.Sprintf(" (%d modified, %d added, %d deleted)", - len(r.Modified), len(r.Added), len(r.Deleted)) - fmt.Print(ui.SuccessStyle.Render(summary)) - fmt.Println(ui.MutedStyle.Render(detail)) - - if len(r.Stale) > 0 { - fmt.Println(ui.MutedStyle.Render(fmt.Sprintf("Cleaned %d stale patches", len(r.Stale)))) - } -} diff --git a/packages/browseros/tools/bdev/cmd/remove.go b/packages/browseros/tools/bdev/cmd/remove.go new file mode 100644 index 000000000..bf1f7a1b6 --- /dev/null +++ b/packages/browseros/tools/bdev/cmd/remove.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "fmt" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui" + "github.com/spf13/cobra" +) + +func init() { + command := &cobra.Command{ + Use: "remove ", + Aliases: []string{"rm"}, + Annotations: map[string]string{"group": "Workspace:"}, + Short: "Unregister a workspace", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + entry, err := appState.Registry.Remove(args[0]) + if err != nil { + return err + } + if err := appState.Save(); err != nil { + return err + } + return renderResult(map[string]any{"workspace": entry}, func() { + fmt.Println(ui.Success("Removed workspace")) + fmt.Printf("%s %s\n", ui.Muted("name:"), entry.Name) + fmt.Printf("%s %s\n", ui.Muted("path:"), entry.Path) + }) + }, + } + rootCmd.AddCommand(command) +} diff --git a/packages/browseros/tools/bdev/cmd/root.go b/packages/browseros/tools/bdev/cmd/root.go index d7590875c..fb7dc1ea7 100644 --- a/packages/browseros/tools/bdev/cmd/root.go +++ b/packages/browseros/tools/bdev/cmd/root.go @@ -1,31 +1,129 @@ package cmd import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/app" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui" "github.com/spf13/cobra" ) +var Version = "dev" + var ( - verbose bool - version string + jsonOut bool + verbose bool + appState *app.App ) +var groupOrder = []string{ + "Workspace:", + "Core:", + "Conflict:", + "Remote:", +} + +func helpHeader(s string) string { return ui.Header(s) } +func helpCmdCol(s string) string { return ui.Command(s) } +func helpHint(s string) string { return ui.Hint(s) } +func helpAliases(aliases []string) string { + return ui.Aliases(aliases) +} + +func groupedHelp(cmd *cobra.Command) string { + groups := map[string][]*cobra.Command{} + for _, child := range cmd.Commands() { + if !child.IsAvailableCommand() && child.Name() != "help" { + continue + } + group := child.Annotations["group"] + if group == "" { + group = "Core:" + } + groups[group] = append(groups[group], child) + } + var builder strings.Builder + for _, group := range groupOrder { + commands, ok := groups[group] + if !ok { + continue + } + builder.WriteString("\n" + helpHeader(group) + "\n") + for _, child := range commands { + line := " " + helpCmdCol(fmt.Sprintf("%-14s", child.Name())) + " " + child.Short + if len(child.Aliases) > 0 { + line += " " + helpAliases(child.Aliases) + } + builder.WriteString(line + "\n") + } + } + return builder.String() +} + +const usageTemplate = `{{helpHeader "Usage:"}}{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +{{helpHeader "Aliases:"}} + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +{{helpHeader "Examples:"}} +{{.Example}}{{end}}{{if .HasAvailableSubCommands}} +{{groupedHelp .}}{{end}}{{if .HasAvailableLocalFlags}} + +{{helpHeader "Flags:"}} +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +{{helpHeader "Global Flags:"}} +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableSubCommands}} + +{{helpHint (printf "Use \"%s [command] --help\" for more information." .CommandPath)}}{{end}} +` + var rootCmd = &cobra.Command{ Use: "bdev", - Short: "BrowserOS CLI — patch management, builds, and releases", - Long: "bdev manages BrowserOS patches across Chromium checkouts.\nUse push/pull to sync patches, clone for fresh applies.", + Short: "Workspace-centric BrowserOS patch tooling for Chromium checkouts", + Version: Version, SilenceUsage: true, SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + var err error + appState, err = app.Load(jsonOut, verbose, "") + return err + }, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, } func init() { - rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "increase output detail") + cobra.AddTemplateFunc("helpHeader", helpHeader) + cobra.AddTemplateFunc("helpCmdCol", helpCmdCol) + cobra.AddTemplateFunc("helpAliases", helpAliases) + cobra.AddTemplateFunc("helpHint", helpHint) + cobra.AddTemplateFunc("groupedHelp", groupedHelp) + rootCmd.SetUsageTemplate(usageTemplate) + rootCmd.PersistentFlags().BoolVar(&jsonOut, "json", false, "Emit JSON output") + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output") + rootCmd.CompletionOptions.DisableDefaultCmd = true } -func SetVersion(v string) { - version = v - rootCmd.Version = v +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } } -func Execute() error { - return rootCmd.Execute() +func renderResult(data any, human func()) error { + if jsonOut { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(data) + } + human() + return nil } diff --git a/packages/browseros/tools/bdev/cmd/skip.go b/packages/browseros/tools/bdev/cmd/skip.go new file mode 100644 index 000000000..f31d1e80e --- /dev/null +++ b/packages/browseros/tools/bdev/cmd/skip.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "fmt" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui" + "github.com/spf13/cobra" +) + +func init() { + command := &cobra.Command{ + Use: "skip", + Annotations: map[string]string{"group": "Conflict:"}, + Short: "Skip the current conflict and move to the next one", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := resolve.FindActive(appState.Registry, appState.CWD) + if err != nil { + return err + } + result, err := engine.Skip(cmd.Context(), ws) + if err != nil { + return err + } + return renderResult(result, func() { + fmt.Println(ui.Warning(fmt.Sprintf("Skipped current conflict in %s", ws.Name))) + }) + }, + } + rootCmd.AddCommand(command) +} diff --git a/packages/browseros/tools/bdev/cmd/status.go b/packages/browseros/tools/bdev/cmd/status.go index 3b0d3c822..383c0f869 100644 --- a/packages/browseros/tools/bdev/cmd/status.go +++ b/packages/browseros/tools/bdev/cmd/status.go @@ -1,96 +1,45 @@ package cmd import ( - "encoding/json" "fmt" - "bdev/internal/config" - "bdev/internal/engine" - "bdev/internal/ui" - + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui" "github.com/spf13/cobra" ) -var statusCmd = &cobra.Command{ - Use: "status", - Short: "Show sync state between checkout and patches repo", - RunE: runStatus, -} - -var ( - statusJSON bool - statusFiles bool -) - func init() { - statusCmd.Flags().BoolVar(&statusJSON, "json", false, "output as JSON") - statusCmd.Flags().BoolVar(&statusFiles, "files", false, "list individual files per category") - rootCmd.AddCommand(statusCmd) -} - -func runStatus(cmd *cobra.Command, args []string) error { - ctx, err := config.LoadContext() - if err != nil { - return err - } - - result, err := engine.Status(ctx, statusFiles) - if err != nil { - return err - } - - if statusJSON { - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - return err - } - fmt.Println(string(data)) - return nil - } - - renderStatus(result) - return nil -} - -func renderStatus(r *engine.StatusResult) { - fmt.Println(ui.TitleStyle.Render("bdev status")) - fmt.Println() - - fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Checkout:"), ui.ValueStyle.Render(r.CheckoutName)) - fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Base commit:"), r.BaseCommit[:min(12, len(r.BaseCommit))]) - if r.ChromiumVersion != "" { - fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Chromium:"), r.ChromiumVersion) - } - fmt.Printf(" %s %s\n", ui.LabelStyle.Render("Patches repo:"), r.PatchesRepo) - - fmt.Println() - fmt.Println(" Sync status:") - - if r.Ahead > 0 { - fmt.Printf(" %s %s\n", - ui.WarningStyle.Render(fmt.Sprintf("ahead: %3d files", r.Ahead)), - ui.MutedStyle.Render("(local changes not in patches repo)")) - } - if r.Behind > 0 { - fmt.Printf(" %s %s\n", - ui.WarningStyle.Render(fmt.Sprintf("behind: %3d files", r.Behind)), - ui.MutedStyle.Render("(patches in repo not applied locally)")) - } - fmt.Printf(" %s\n", - ui.SuccessStyle.Render(fmt.Sprintf("synced: %3d files", r.Synced))) - - if len(r.AheadFiles) > 0 { - fmt.Println() - fmt.Println(" Ahead files:") - for _, f := range r.AheadFiles { - fmt.Printf(" %s %s\n", ui.AddedPrefix, f) - } - } - if len(r.BehindFiles) > 0 { - fmt.Println() - fmt.Println(" Behind files:") - for _, f := range r.BehindFiles { - fmt.Printf(" %s %s\n", ui.WarningStyle.Render(">"), f) - } - } + var src string + command := &cobra.Command{ + Use: "status [workspace]", + Annotations: map[string]string{"group": "Core:"}, + Short: "Show workspace sync state", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := resolveWorkspace(args, src) + if err != nil { + return err + } + info, err := repoInfo() + if err != nil { + return err + } + status, err := engine.InspectWorkspace(cmd.Context(), ws, info) + if err != nil { + return err + } + return renderResult(status, func() { + fmt.Println(ui.Title(fmt.Sprintf("%s (%s)", ws.Name, status.SyncState))) + fmt.Printf("%s %s\n", ui.Muted("path:"), ws.Path) + fmt.Printf("%s %s\n", ui.Muted("repo head:"), status.RepoHead) + fmt.Printf("%s %s\n", ui.Muted("last sync:"), status.LastSyncRev) + fmt.Printf("%s %s\n", ui.Muted("last apply:"), status.LastApplyRev) + fmt.Printf("%s %d\n", ui.Muted("needs apply:"), len(status.NeedsApply)) + fmt.Printf("%s %d\n", ui.Muted("needs update:"), len(status.NeedsUpdate)) + fmt.Printf("%s %d\n", ui.Muted("orphaned:"), len(status.Orphaned)) + }) + }, + } + command.Flags().StringVar(&src, "src", "", "Chromium checkout path to operate on directly") + rootCmd.AddCommand(command) } diff --git a/packages/browseros/tools/bdev/cmd/sync.go b/packages/browseros/tools/bdev/cmd/sync.go new file mode 100644 index 000000000..0284703ea --- /dev/null +++ b/packages/browseros/tools/bdev/cmd/sync.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "fmt" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/engine" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/ui" + "github.com/spf13/cobra" +) + +func init() { + var src string + var rebase bool + var remote string + command := &cobra.Command{ + Use: "sync [workspace]", + Annotations: map[string]string{"group": "Core:"}, + Short: "Sync a workspace with the latest patch repo state", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := resolveWorkspace(args, src) + if err != nil { + return err + } + info, err := repoInfo() + if err != nil { + return err + } + result, err := engine.Sync(cmd.Context(), engine.SyncOptions{ + Workspace: ws, + Repo: info, + Remote: remote, + Rebase: rebase, + }) + if err != nil { + return err + } + return renderResult(result, func() { + fmt.Println(ui.Title(fmt.Sprintf("Synced %s", ws.Name))) + fmt.Printf("%s %s\n", ui.Muted("repo head:"), result.RepoHead) + fmt.Printf("%s %d\n", ui.Muted("applied:"), len(result.Applied)) + if result.StashRef != "" { + fmt.Printf("%s %s\n", ui.Muted("stash:"), result.StashRef) + } + if len(result.Conflicts) > 0 { + fmt.Println(ui.Warning("Conflicts detected")) + for _, conflict := range result.Conflicts { + fmt.Printf(" %s\n", conflict) + } + } + }) + }, + } + command.Flags().StringVar(&src, "src", "", "Chromium checkout path to operate on directly") + command.Flags().BoolVar(&rebase, "rebase", false, "Re-apply stashed local changes after syncing") + command.Flags().StringVar(&remote, "remote", "origin", "Remote to pull from") + rootCmd.AddCommand(command) +} diff --git a/packages/browseros/tools/bdev/cmd/sync_args.go b/packages/browseros/tools/bdev/cmd/sync_args.go deleted file mode 100644 index 0fd29a1f4..000000000 --- a/packages/browseros/tools/bdev/cmd/sync_args.go +++ /dev/null @@ -1,44 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - "bdev/internal/git" -) - -func resolveRemoteAndFiles(repoDir string, args []string, explicitRemote string) (string, []string, error) { - remote := strings.TrimSpace(explicitRemote) - if remote != "" { - hasRemote, err := git.HasRemote(repoDir, remote) - if err != nil { - return "", nil, fmt.Errorf("resolving remote %q: %w", remote, err) - } - if !hasRemote { - return "", nil, fmt.Errorf("remote %q not found in patches repo", remote) - } - return remote, args, nil - } - - if len(args) == 0 { - return "", nil, nil - } - - hasRemote, err := git.HasRemote(repoDir, args[0]) - if err != nil { - return "", nil, fmt.Errorf("resolving remote %q: %w", args[0], err) - } - if hasRemote { - return args[0], args[1:], nil - } - - return "", args, nil -} - -func shortRev(rev string) string { - rev = strings.TrimSpace(rev) - if len(rev) <= 12 { - return rev - } - return rev[:12] -} diff --git a/packages/browseros/tools/bdev/cmd/sync_args_test.go b/packages/browseros/tools/bdev/cmd/sync_args_test.go deleted file mode 100644 index 99ee41fe7..000000000 --- a/packages/browseros/tools/bdev/cmd/sync_args_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package cmd - -import ( - "os" - "os/exec" - "path/filepath" - "reflect" - "testing" -) - -func TestResolveRemoteAndFiles(t *testing.T) { - t.Parallel() - - repo := initRemoteRepo(t) - - remote, files, err := resolveRemoteAndFiles(repo, []string{"origin", "content/foo.cc"}, "") - if err != nil { - t.Fatalf("resolveRemoteAndFiles: %v", err) - } - if remote != "origin" { - t.Fatalf("expected origin, got %q", remote) - } - if !reflect.DeepEqual(files, []string{"content/foo.cc"}) { - t.Fatalf("unexpected files: %#v", files) - } -} - -func TestResolveRemoteAndFilesUnknownExplicitRemote(t *testing.T) { - t.Parallel() - - repo := initRemoteRepo(t) - if _, _, err := resolveRemoteAndFiles(repo, nil, "missing"); err == nil { - t.Fatalf("expected error for unknown explicit remote") - } -} - -func initRemoteRepo(t *testing.T) string { - t.Helper() - - dir := filepath.Join(t.TempDir(), "patches") - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - - runGitCmd(t, dir, "init") - runGitCmd(t, dir, "remote", "add", "origin", "https://example.com/org/repo.git") - return dir -} - -func runGitCmd(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, string(out)) - } - return string(out) -} diff --git a/packages/browseros/tools/bdev/e2e/main_test.go b/packages/browseros/tools/bdev/e2e/main_test.go deleted file mode 100644 index 967ff1abf..000000000 --- a/packages/browseros/tools/bdev/e2e/main_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package e2e - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "testing" -) - -var bdevBinary string - -func TestMain(m *testing.M) { - tmpDir, err := os.MkdirTemp("", "bdev-e2e-bin-*") - if err != nil { - fmt.Fprintf(os.Stderr, "failed to create temp dir: %v\n", err) - os.Exit(1) - } - - _, file, _, ok := runtime.Caller(0) - if !ok { - fmt.Fprintln(os.Stderr, "failed to resolve e2e test path") - os.Exit(1) - } - - moduleDir := filepath.Clean(filepath.Join(filepath.Dir(file), "..")) - bdevBinary = filepath.Join(tmpDir, "bdev-e2e") - - build := exec.Command("go", "build", "-o", bdevBinary, ".") - build.Dir = moduleDir - if out, err := build.CombinedOutput(); err != nil { - fmt.Fprintf(os.Stderr, "failed to build bdev binary: %v\n%s\n", err, string(out)) - os.Exit(1) - } - - code := m.Run() - _ = os.RemoveAll(tmpDir) - os.Exit(code) -} diff --git a/packages/browseros/tools/bdev/e2e/operations_test.go b/packages/browseros/tools/bdev/e2e/operations_test.go deleted file mode 100644 index b12c25a7b..000000000 --- a/packages/browseros/tools/bdev/e2e/operations_test.go +++ /dev/null @@ -1,244 +0,0 @@ -package e2e - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -type scenario struct { - root string - baseCommit string - - patchesRemote string - patchesRepo string - chromiumA string - chromiumB string - - trackedPath string - newPath string -} - -type statusJSON struct { - Ahead int - Behind int - Synced int -} - -func TestBdevOperationsE2E(t *testing.T) { - env := setupScenario(t) - - runBdev(t, env.chromiumA, "init", "--patches-repo", env.patchesRepo, "--name", "checkout-a") - - statusBefore := readStatus(t, env.chromiumA) - if statusBefore.Behind == 0 { - t.Fatalf("expected checkout-a to be behind before pull, got %#v", statusBefore) - } - - pullPreview := runBdev(t, env.chromiumA, "diff", "--direction", "pull") - assertContains(t, pullPreview, env.trackedPath) - - runBdev(t, env.chromiumA, "pull", "--no-sync") - assertFileContains(t, filepath.Join(env.chromiumA, env.trackedPath), "patch-v1") - - statusAfterPull := readStatus(t, env.chromiumA) - if statusAfterPull.Behind != 0 || statusAfterPull.Synced == 0 { - t.Fatalf("unexpected status after pull: %#v", statusAfterPull) - } - - writeFile(t, filepath.Join(env.chromiumA, "base", ".keep"), "my-local-work\n") - pullAgain := runBdev(t, env.chromiumA, "pull", "--no-sync", "base/.keep") - assertContains(t, pullAgain, "local-only, kept") - assertFileContains(t, filepath.Join(env.chromiumA, "base", ".keep"), "my-local-work") - runGit(t, env.chromiumA, "checkout", env.baseCommit, "--", "base/.keep") - - writeFile(t, filepath.Join(env.chromiumA, env.trackedPath), "patch-v2\n") - writeFile(t, filepath.Join(env.chromiumA, env.newPath), "brand-new\n") - pushPreview := runBdev(t, env.chromiumA, "diff", "--direction", "push") - assertContains(t, pushPreview, env.trackedPath) - - runBdev(t, env.chromiumA, "push", "--no-sync", env.trackedPath, env.newPath) - assertFileContains(t, filepath.Join(env.patchesRepo, "chromium_patches", env.newPath), "diff --git") - - // Keep the patches repo clean before remote-aware publish flow. - commitRepo(t, env.patchesRepo, "chore: e2e checkpoint after push --no-sync") - - writeFile(t, filepath.Join(env.chromiumA, env.trackedPath), "patch-v3\n") - publish := runBdev(t, env.chromiumA, "push", "origin", "-m", "e2e: publish patch-v3", env.trackedPath) - assertContains(t, publish, "remote publish complete") - - mirror := filepath.Join(env.root, "mirror") - runGit(t, env.root, "clone", env.patchesRemote, mirror) - assertFileContains(t, filepath.Join(mirror, "chromium_patches", env.trackedPath), "patch-v3") - - collab := filepath.Join(env.root, "collab") - runGit(t, env.root, "clone", env.patchesRemote, collab) - configRepo(t, collab) - diffV4 := buildDiffFromBase(t, env.chromiumA, env.baseCommit, env.trackedPath, "patch-v4\n") - writeFile(t, filepath.Join(collab, "chromium_patches", env.trackedPath), diffV4) - commitRepo(t, collab, "feat: remote patch-v4 update") - branch := strings.TrimSpace(runGit(t, collab, "symbolic-ref", "--short", "HEAD")) - runGit(t, collab, "push", "origin", "HEAD:"+branch) - - runBdev(t, env.chromiumA, "pull", "origin") - assertFileContains(t, filepath.Join(env.chromiumA, env.trackedPath), "patch-v4") - - runBdev(t, env.chromiumB, "clone", "--patches-repo", env.patchesRepo, "--verify-base", "--clean", "--name", "checkout-b") - assertFileContains(t, filepath.Join(env.chromiumB, env.trackedPath), "patch-v4") - statusB := readStatus(t, env.chromiumB) - if statusB.Ahead != 0 || statusB.Synced == 0 { - t.Fatalf("expected checkout-b to have clean/synced clone state, got %#v", statusB) - } -} - -func setupScenario(t *testing.T) *scenario { - t.Helper() - - root := t.TempDir() - patchesRemote := filepath.Join(root, "patches-remote.git") - chromiumA := filepath.Join(root, "chromium-a") - chromiumB := filepath.Join(root, "chromium-b") - patchesRepo := filepath.Join(root, "patches") - trackedPath := filepath.ToSlash(filepath.Join("chrome", "app", "test.txt")) - newPath := filepath.ToSlash(filepath.Join("chrome", "browser", "new_file.txt")) - - runGit(t, root, "init", "--bare", patchesRemote) - setupChromiumRepo(t, chromiumA) - - writeFile(t, filepath.Join(chromiumA, trackedPath), "base\n") - runGit(t, chromiumA, "add", "-A") - runGit(t, chromiumA, "commit", "-m", "base") - baseCommit := strings.TrimSpace(runGit(t, chromiumA, "rev-parse", "HEAD")) - - diffV1 := buildDiffFromBase(t, chromiumA, baseCommit, trackedPath, "patch-v1\n") - - runGit(t, root, "clone", patchesRemote, patchesRepo) - configRepo(t, patchesRepo) - writeFile(t, filepath.Join(patchesRepo, "BASE_COMMIT"), baseCommit+"\n") - writeFile(t, filepath.Join(patchesRepo, "CHROMIUM_VERSION"), "MAJOR=145\nMINOR=0\nBUILD=7632\nPATCH=45\n") - writeFile(t, filepath.Join(patchesRepo, "chromium_patches", trackedPath), diffV1) - commitRepo(t, patchesRepo, "seed patches") - branch := strings.TrimSpace(runGit(t, patchesRepo, "symbolic-ref", "--short", "HEAD")) - runGit(t, patchesRepo, "push", "-u", "origin", "HEAD:"+branch) - - runGit(t, root, "clone", chromiumA, chromiumB) - configRepo(t, chromiumB) - - return &scenario{ - root: root, - baseCommit: baseCommit, - patchesRemote: patchesRemote, - patchesRepo: patchesRepo, - chromiumA: chromiumA, - chromiumB: chromiumB, - trackedPath: trackedPath, - newPath: newPath, - } -} - -func setupChromiumRepo(t *testing.T, dir string) { - t.Helper() - if err := os.MkdirAll(filepath.Join(dir, "chrome"), 0o755); err != nil { - t.Fatalf("mkdir chrome: %v", err) - } - if err := os.MkdirAll(filepath.Join(dir, "base"), 0o755); err != nil { - t.Fatalf("mkdir base: %v", err) - } - writeFile(t, filepath.Join(dir, "base", ".keep"), "marker\n") - runGit(t, dir, "init") - configRepo(t, dir) -} - -func buildDiffFromBase(t *testing.T, repo, base, relPath, content string) string { - t.Helper() - abs := filepath.Join(repo, relPath) - original := mustRead(t, abs) - writeFile(t, abs, content) - diff := runGit(t, repo, "diff", "--full-index", base, "--", relPath) - writeFile(t, abs, original) - if strings.TrimSpace(diff) == "" { - t.Fatalf("expected non-empty diff for %s", relPath) - } - return diff -} - -func readStatus(t *testing.T, chromiumDir string) statusJSON { - t.Helper() - raw := runBdev(t, chromiumDir, "status", "--json") - var s statusJSON - if err := json.Unmarshal([]byte(raw), &s); err != nil { - t.Fatalf("failed to parse status json: %v\nraw=%s", err, raw) - } - return s -} - -func runBdev(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command(bdevBinary, args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("bdev %v failed: %v\n%s", args, err, string(out)) - } - return string(out) -} - -func runGit(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, string(out)) - } - return string(out) -} - -func commitRepo(t *testing.T, dir, message string) { - t.Helper() - runGit(t, dir, "add", "-A") - runGit(t, dir, "commit", "-m", message) -} - -func configRepo(t *testing.T, dir string) { - t.Helper() - runGit(t, dir, "config", "user.email", "bdev-e2e@example.com") - runGit(t, dir, "config", "user.name", "bdev e2e") -} - -func writeFile(t *testing.T, path, content string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", path, err) - } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } -} - -func mustRead(t *testing.T, path string) string { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read %s: %v", path, err) - } - return string(data) -} - -func assertContains(t *testing.T, output, want string) { - t.Helper() - if !strings.Contains(output, want) { - t.Fatalf("expected output to contain %q\noutput:\n%s", want, output) - } -} - -func assertFileContains(t *testing.T, path, want string) { - t.Helper() - content := mustRead(t, path) - if !strings.Contains(content, want) { - t.Fatalf("expected %s to contain %q\ncontent:\n%s", path, want, content) - } -} diff --git a/packages/browseros/tools/bdev/go.mod b/packages/browseros/tools/bdev/go.mod index dade44abe..d8981a88b 100644 --- a/packages/browseros/tools/bdev/go.mod +++ b/packages/browseros/tools/bdev/go.mod @@ -1,11 +1,10 @@ -module bdev +module github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev -go 1.25.7 +go 1.25.0 require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.10.2 - golang.org/x/sync v0.19.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/packages/browseros/tools/bdev/go.sum b/packages/browseros/tools/bdev/go.sum index 6f9e4c1de..72909c145 100644 --- a/packages/browseros/tools/bdev/go.sum +++ b/packages/browseros/tools/bdev/go.sum @@ -1,5 +1,7 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= @@ -8,6 +10,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -34,8 +38,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/packages/browseros/tools/bdev/internal/app/app.go b/packages/browseros/tools/bdev/internal/app/app.go new file mode 100644 index 000000000..e5ceb9431 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/app/app.go @@ -0,0 +1,67 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace" +) + +type App struct { + JSON bool + Verbose bool + CWD string + Config *workspace.Config + Registry *workspace.Registry +} + +func Load(jsonOut bool, verbose bool, cwd string) (*App, error) { + if cwd == "" { + var err error + cwd, err = os.Getwd() + if err != nil { + return nil, err + } + } + cfg, err := workspace.LoadConfig() + if err != nil { + return nil, err + } + reg, err := workspace.LoadRegistry() + if err != nil { + return nil, err + } + return &App{ + JSON: jsonOut, + Verbose: verbose, + CWD: filepath.Clean(cwd), + Config: cfg, + Registry: reg, + }, nil +} + +func (a *App) Save() error { + if err := workspace.SaveConfig(a.Config); err != nil { + return err + } + return workspace.SaveRegistry(a.Registry) +} + +func (a *App) ResolveWorkspace(name string, src string) (workspace.Entry, error) { + return workspace.Resolve(a.Registry, name, a.CWD, src) +} + +func (a *App) RepoInfo() (*repo.Info, error) { + if a.Config.PatchesRepo == "" { + discovered, err := repo.Discover(a.CWD) + if err != nil { + return nil, fmt.Errorf( + `patches repo is not configured; run "bdev add --patches-repo " from the browseros repo once`, + ) + } + return repo.Load(discovered) + } + return repo.Load(a.Config.PatchesRepo) +} diff --git a/packages/browseros/tools/bdev/internal/config/base.go b/packages/browseros/tools/bdev/internal/config/base.go deleted file mode 100644 index 4b74d8883..000000000 --- a/packages/browseros/tools/bdev/internal/config/base.go +++ /dev/null @@ -1,57 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -func ReadBaseCommit(patchesRepo string) (string, error) { - path := filepath.Join(patchesRepo, "BASE_COMMIT") - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return "", fmt.Errorf("BASE_COMMIT not found in %s — create it with the Chromium commit hash", patchesRepo) - } - return "", fmt.Errorf("reading BASE_COMMIT: %w", err) - } - commit := strings.TrimSpace(string(data)) - if commit == "" { - return "", fmt.Errorf("BASE_COMMIT is empty in %s", patchesRepo) - } - return commit, nil -} - -func ReadChromiumVersion(patchesRepo string) (string, error) { - path := filepath.Join(patchesRepo, "CHROMIUM_VERSION") - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return "", nil - } - return "", fmt.Errorf("reading CHROMIUM_VERSION: %w", err) - } - - vars := make(map[string]string) - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - vars[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) - } - } - - major := vars["MAJOR"] - minor := vars["MINOR"] - build := vars["BUILD"] - patch := vars["PATCH"] - - if major == "" { - return "", nil - } - return fmt.Sprintf("%s.%s.%s.%s", major, minor, build, patch), nil -} diff --git a/packages/browseros/tools/bdev/internal/config/config.go b/packages/browseros/tools/bdev/internal/config/config.go deleted file mode 100644 index 090961df7..000000000 --- a/packages/browseros/tools/bdev/internal/config/config.go +++ /dev/null @@ -1,41 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" -) - -type Config struct { - Name string `yaml:"name"` - PatchesRepo string `yaml:"patches_repo"` -} - -func ReadConfig(brosDir string) (*Config, error) { - data, err := os.ReadFile(filepath.Join(brosDir, "config.yaml")) - if err != nil { - return nil, fmt.Errorf("reading config: %w", err) - } - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("parsing config.yaml: %w", err) - } - return &cfg, nil -} - -func WriteConfig(brosDir string, cfg *Config) error { - if err := os.MkdirAll(brosDir, 0o755); err != nil { - return fmt.Errorf("creating .bros directory: %w", err) - } - data, err := yaml.Marshal(cfg) - if err != nil { - return fmt.Errorf("marshaling config: %w", err) - } - path := filepath.Join(brosDir, "config.yaml") - if err := os.WriteFile(path, data, 0o644); err != nil { - return fmt.Errorf("writing config.yaml: %w", err) - } - return nil -} diff --git a/packages/browseros/tools/bdev/internal/config/resolve.go b/packages/browseros/tools/bdev/internal/config/resolve.go deleted file mode 100644 index 258a955f0..000000000 --- a/packages/browseros/tools/bdev/internal/config/resolve.go +++ /dev/null @@ -1,101 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" -) - -const BrosDirName = ".bros" - -// Context holds everything needed for an operation. -type Context struct { - Config *Config - State *State - ChromiumDir string // Absolute path to chromium checkout (parent of .bros/) - BrosDir string // Absolute path to .bros/ - PatchesRepo string // Absolute path to patches repo root - PatchesDir string // Absolute path to chromium_patches/ - BaseCommit string - ChromiumVersion string -} - -// FindBrosDir walks up from cwd to find the nearest .bros/ directory. -func FindBrosDir() (string, error) { - dir, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("getting cwd: %w", err) - } - - for { - candidate := filepath.Join(dir, BrosDirName) - if info, err := os.Stat(candidate); err == nil && info.IsDir() { - return dir, nil - } - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } - - return "", fmt.Errorf("not a bdev checkout (no .bros/ found in any parent directory)") -} - -// LoadContext loads config, state, and patches repo info. -func LoadContext() (*Context, error) { - chromiumDir, err := FindBrosDir() - if err != nil { - return nil, err - } - brosDir := filepath.Join(chromiumDir, BrosDirName) - - cfg, err := ReadConfig(brosDir) - if err != nil { - return nil, fmt.Errorf("loading config: %w", err) - } - - state, err := ReadState(brosDir) - if err != nil { - return nil, fmt.Errorf("loading state: %w", err) - } - - patchesRepo := cfg.PatchesRepo - if !filepath.IsAbs(patchesRepo) { - patchesRepo = filepath.Join(chromiumDir, patchesRepo) - } - - patchesDir := filepath.Join(patchesRepo, "chromium_patches") - if _, err := os.Stat(patchesDir); err != nil { - return nil, fmt.Errorf("patches directory not found: %s", patchesDir) - } - - baseCommit, err := ReadBaseCommit(patchesRepo) - if err != nil { - return nil, err - } - - chromiumVersion, _ := ReadChromiumVersion(patchesRepo) - - return &Context{ - Config: cfg, - State: state, - ChromiumDir: chromiumDir, - BrosDir: brosDir, - PatchesRepo: patchesRepo, - PatchesDir: patchesDir, - BaseCommit: baseCommit, - ChromiumVersion: chromiumVersion, - }, nil -} - -// LooksLikeChromium checks if a directory looks like a Chromium source tree. -func LooksLikeChromium(dir string) bool { - markers := []string{"chrome", "base", ".git"} - for _, m := range markers { - if _, err := os.Stat(filepath.Join(dir, m)); err != nil { - return false - } - } - return true -} diff --git a/packages/browseros/tools/bdev/internal/config/state.go b/packages/browseros/tools/bdev/internal/config/state.go deleted file mode 100644 index 7e04d3cd4..000000000 --- a/packages/browseros/tools/bdev/internal/config/state.go +++ /dev/null @@ -1,50 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - "time" - - "gopkg.in/yaml.v3" -) - -type State struct { - LastPull *SyncEvent `yaml:"last_pull,omitempty"` - LastPush *SyncEvent `yaml:"last_push,omitempty"` -} - -type SyncEvent struct { - PatchesRepoRev string `yaml:"patches_repo_rev"` - BaseCommit string `yaml:"base_commit,omitempty"` - Timestamp time.Time `yaml:"timestamp"` - FileCount int `yaml:"file_count"` -} - -func ReadState(brosDir string) (*State, error) { - path := filepath.Join(brosDir, "state.yaml") - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return &State{}, nil - } - return nil, fmt.Errorf("reading state: %w", err) - } - var s State - if err := yaml.Unmarshal(data, &s); err != nil { - return nil, fmt.Errorf("parsing state.yaml: %w", err) - } - return &s, nil -} - -func WriteState(brosDir string, s *State) error { - data, err := yaml.Marshal(s) - if err != nil { - return fmt.Errorf("marshaling state: %w", err) - } - path := filepath.Join(brosDir, "state.yaml") - if err := os.WriteFile(path, data, 0o644); err != nil { - return fmt.Errorf("writing state.yaml: %w", err) - } - return nil -} diff --git a/packages/browseros/tools/bdev/internal/engine/apply.go b/packages/browseros/tools/bdev/internal/engine/apply.go new file mode 100644 index 000000000..cc0bcace0 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/engine/apply.go @@ -0,0 +1,410 @@ +package engine + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/patch" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace" +) + +type ApplyOptions struct { + Workspace workspace.Entry + Repo *repo.Info + Reset bool + ChangedRef string + RangeEnd string + Filters []string + Mode string +} + +type ApplyResult struct { + Workspace string `json:"workspace"` + Mode string `json:"mode"` + BaseCommit string `json:"base_commit"` + RepoRev string `json:"repo_rev"` + Applied []string `json:"applied"` + ResetPaths []string `json:"reset_paths"` + Orphaned []string `json:"orphaned,omitempty"` + Conflicts []resolve.Operation `json:"conflicts,omitempty"` +} + +func Apply(ctx context.Context, opts ApplyOptions) (*ApplyResult, error) { + repoRev, err := git.HeadRev(ctx, opts.Repo.Root) + if err != nil { + return nil, err + } + ops, orphaned, err := buildApplyOperations(ctx, opts) + if err != nil { + return nil, err + } + result := &ApplyResult{ + Workspace: opts.Workspace.Name, + Mode: applyMode(opts), + BaseCommit: opts.Repo.BaseCommit, + RepoRev: repoRev, + Orphaned: orphaned, + } + if len(ops) == 0 { + if err := markApplyComplete(opts.Workspace.Path, opts.Repo.BaseCommit, repoRev); err != nil { + return nil, err + } + if err := clearResolveState(opts.Workspace.Path); err != nil { + return nil, err + } + return result, nil + } + next, err := applyOperationRange(ctx, opts.Workspace, opts.Repo, ops, 0, nil, nil, result) + if err != nil { + return nil, err + } + if next < len(ops) { + return result, nil + } + if err := markApplyComplete(opts.Workspace.Path, opts.Repo.BaseCommit, repoRev); err != nil { + return nil, err + } + if err := clearResolveState(opts.Workspace.Path); err != nil { + return nil, err + } + return result, nil +} + +func Continue(ctx context.Context, ws workspace.Entry) (*ApplyResult, error) { + state, err := resolve.Load(ws.Path) + if err != nil { + return nil, err + } + repoInfo, err := repo.Load(state.RepoRoot) + if err != nil { + return nil, err + } + current, err := state.CurrentOperation() + if err != nil { + return nil, err + } + if err := verifyResolved(ctx, ws.Path, repoInfo, current, state.BaseCommit); err != nil { + return nil, err + } + state.Resolved = append(state.Resolved, current.ChromiumPath) + result := &ApplyResult{ + Workspace: ws.Name, + Mode: state.Mode, + BaseCommit: state.BaseCommit, + RepoRev: state.RepoRev, + Applied: append([]string{}, state.Resolved...), + Conflicts: nil, + } + next, err := applyOperationRange(ctx, ws, repoInfo, state.Operations, state.Current+1, state.Resolved, state.Skipped, result) + if err != nil { + return nil, err + } + if next >= len(state.Operations) && len(result.Conflicts) == 0 { + if err := markApplyComplete(ws.Path, state.BaseCommit, state.RepoRev); err != nil { + return nil, err + } + if err := resolve.Delete(ws.Path); err != nil { + return nil, err + } + } + return result, nil +} + +func Skip(ctx context.Context, ws workspace.Entry) (*ApplyResult, error) { + state, err := resolve.Load(ws.Path) + if err != nil { + return nil, err + } + repoInfo, err := repo.Load(state.RepoRoot) + if err != nil { + return nil, err + } + current, err := state.CurrentOperation() + if err != nil { + return nil, err + } + state.Skipped = append(state.Skipped, current.ChromiumPath) + result := &ApplyResult{ + Workspace: ws.Name, + Mode: state.Mode, + BaseCommit: state.BaseCommit, + RepoRev: state.RepoRev, + Applied: append([]string{}, state.Resolved...), + } + next, err := applyOperationRange(ctx, ws, repoInfo, state.Operations, state.Current+1, state.Resolved, state.Skipped, result) + if err != nil { + return nil, err + } + if next >= len(state.Operations) && len(result.Conflicts) == 0 { + if err := markApplyComplete(ws.Path, state.BaseCommit, state.RepoRev); err != nil { + return nil, err + } + if err := resolve.Delete(ws.Path); err != nil { + return nil, err + } + } + return result, nil +} + +func Abort(ctx context.Context, ws workspace.Entry) error { + state, err := resolve.Load(ws.Path) + if err != nil { + return err + } + for idx := 0; idx < len(state.Operations); idx++ { + op := state.Operations[idx] + if op.OldPath != "" { + if err := git.ResetPathToCommit(ctx, ws.Path, state.BaseCommit, op.OldPath); err != nil { + return err + } + } + if err := git.ResetPathToCommit(ctx, ws.Path, state.BaseCommit, op.ChromiumPath); err != nil { + return err + } + if op.RejectPath != "" { + _ = os.Remove(op.RejectPath) + } + } + workspaceState, err := workspace.LoadState(ws.Path) + if err != nil { + return err + } + if err := resolve.Delete(ws.Path); err != nil { + return err + } + if workspaceState.PendingStash == "" { + return nil + } + if err := git.StashPop(ctx, ws.Path, workspaceState.PendingStash); err != nil { + return err + } + workspaceState.PendingStash = "" + return workspace.SaveState(ws.Path, workspaceState) +} + +func buildApplyOperations(ctx context.Context, opts ApplyOptions) ([]resolve.Operation, []string, error) { + repoSet, err := patch.LoadRepoPatchSet(opts.Repo.PatchesDir, opts.Filters) + if err != nil { + return nil, nil, err + } + switch { + case opts.Reset: + return operationsFromPatchSet(repoSet), nil, nil + case opts.ChangedRef != "": + changes, err := repoPatchChanges(ctx, opts.Repo, opts.ChangedRef, opts.RangeEnd) + if err != nil { + return nil, nil, err + } + return operationsFromChanges(repoSet, changes, opts.Filters), nil, nil + default: + localSet, err := patch.BuildWorkingTreePatchSet(ctx, opts.Workspace.Path, opts.Repo.BaseCommit, opts.Filters) + if err != nil { + return nil, nil, err + } + var ops []resolve.Operation + var orphaned []string + for _, delta := range patch.Compare(repoSet, localSet) { + switch delta.Kind { + case patch.NeedsApply, patch.NeedsUpdate: + ops = append(ops, operationFromPatch(*delta.Repo)) + case patch.Orphaned: + orphaned = append(orphaned, delta.Path) + } + } + return ops, orphaned, nil + } +} + +func applyMode(opts ApplyOptions) string { + switch { + case opts.Mode != "": + return opts.Mode + case opts.Reset: + return "reset" + case opts.ChangedRef != "": + return "changed" + default: + return "incremental" + } +} + +func applyOperationRange( + ctx context.Context, + ws workspace.Entry, + repoInfo *repo.Info, + ops []resolve.Operation, + start int, + resolved []string, + skipped []string, + result *ApplyResult, +) (int, error) { + repoSet, err := patch.LoadRepoPatchSet(repoInfo.PatchesDir, nil) + if err != nil { + return start, err + } + for idx := start; idx < len(ops); idx++ { + op := ops[idx] + result.ResetPaths = append(result.ResetPaths, op.ChromiumPath) + if op.OldPath != "" { + if err := git.ResetPathToCommit(ctx, ws.Path, repoInfo.BaseCommit, op.OldPath); err != nil { + return idx, err + } + } + if err := git.ResetPathToCommit(ctx, ws.Path, repoInfo.BaseCommit, op.ChromiumPath); err != nil { + return idx, err + } + patchFile, ok := repoSet[op.ChromiumPath] + if ok { + if err := applySingleOperation(ctx, ws.Path, patchFile); err != nil { + op.RejectPath = rejectPath(ws.Path, op) + op.Message = err.Error() + ops[idx] = op + state := &resolve.State{ + Workspace: ws.Path, + RepoRoot: repoInfo.Root, + BaseCommit: repoInfo.BaseCommit, + RepoRev: result.RepoRev, + Mode: result.Mode, + Current: idx, + Operations: ops, + Resolved: append([]string{}, resolved...), + Skipped: append([]string{}, skipped...), + } + if err := resolve.Save(ws.Path, state); err != nil { + return idx, err + } + result.Conflicts = []resolve.Operation{op} + return idx, nil + } + } else if op.Op == patch.OpDelete { + if err := os.RemoveAll(filepath.Join(ws.Path, filepath.FromSlash(op.ChromiumPath))); err != nil { + return idx, err + } + } + result.Applied = append(result.Applied, op.ChromiumPath) + } + return len(ops), nil +} + +func applySingleOperation(ctx context.Context, workspacePath string, patchFile patch.FilePatch) error { + switch { + case patchFile.Op == patch.OpDelete: + return os.RemoveAll(filepath.Join(workspacePath, filepath.FromSlash(patchFile.Path))) + case patchFile.IsPureRename(): + from := filepath.Join(workspacePath, filepath.FromSlash(patchFile.OldPath)) + to := filepath.Join(workspacePath, filepath.FromSlash(patchFile.Path)) + if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { + return err + } + return os.Rename(from, to) + case patchFile.Op == patch.OpBinary && len(patchFile.Content) == 0: + return fmt.Errorf("binary markers are not directly applicable: %s", patchFile.Path) + default: + _, err := git.ApplyPatch(ctx, workspacePath, patchFile.Content) + return err + } +} + +func verifyResolved(ctx context.Context, workspacePath string, repoInfo *repo.Info, op resolve.Operation, base string) error { + repoSet, err := patch.LoadRepoPatchSet(repoInfo.PatchesDir, []string{op.ChromiumPath}) + if err != nil { + return err + } + localSet, err := patch.BuildWorkingTreePatchSet(ctx, workspacePath, base, []string{op.ChromiumPath}) + if err != nil { + return err + } + for _, delta := range patch.Compare(repoSet, localSet) { + if delta.Path == op.ChromiumPath && delta.Kind == patch.UpToDate { + if op.RejectPath != "" { + _ = os.Remove(op.RejectPath) + } + return nil + } + } + return fmt.Errorf("current conflict is not resolved yet for %s", op.ChromiumPath) +} + +func operationFromPatch(p patch.FilePatch) resolve.Operation { + return resolve.Operation{ + ChromiumPath: p.Path, + PatchRel: p.Path, + Op: p.Op, + OldPath: p.OldPath, + } +} + +func operationsFromPatchSet(set patch.PatchSet) []resolve.Operation { + paths := patch.ScopeFromSet(set) + ops := make([]resolve.Operation, 0, len(paths)) + for _, rel := range paths { + ops = append(ops, operationFromPatch(set[rel])) + } + return ops +} + +func operationsFromChanges(repoSet patch.PatchSet, changes []git.FileChange, filters []string) []resolve.Operation { + var ops []resolve.Operation + for _, change := range changes { + rel := normalizeChangedPatchPath(change.Path) + if !patch.PathMatches(rel, filters) { + continue + } + if patchFile, ok := repoSet[rel]; ok { + ops = append(ops, operationFromPatch(patchFile)) + continue + } + ops = append(ops, resolve.Operation{ + ChromiumPath: rel, + PatchRel: rel, + Op: patch.OpDelete, + OldPath: normalizeChangedPatchPath(change.OldPath), + }) + } + return ops +} + +func repoPatchChanges(ctx context.Context, repoInfo *repo.Info, ref string, rangeEnd string) ([]git.FileChange, error) { + pathspecs := []string{"chromium_patches"} + if rangeEnd == "" { + return git.DiffTreeNameStatus(ctx, repoInfo.Root, ref, pathspecs) + } + return git.DiffNameStatusBetween(ctx, repoInfo.Root, ref, rangeEnd, pathspecs) +} + +func rejectPath(workspacePath string, op resolve.Operation) string { + candidate := patch.RejectPath(workspacePath, op.ChromiumPath) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + return "" +} + +func normalizeChangedPatchPath(path string) string { + return strings.TrimPrefix(patch.NormalizeChromiumPath(path), "chromium_patches/") +} + +func clearResolveState(workspacePath string) error { + if resolve.Exists(workspacePath) { + return resolve.Delete(workspacePath) + } + return nil +} + +func markApplyComplete(workspacePath string, baseCommit string, repoRev string) error { + state, err := workspace.LoadState(workspacePath) + if err != nil { + return err + } + state.BaseCommit = baseCommit + state.LastApplyRev = repoRev + state.LastApplyAt = time.Now().UTC() + return workspace.SaveState(workspacePath, state) +} diff --git a/packages/browseros/tools/bdev/internal/engine/clone.go b/packages/browseros/tools/bdev/internal/engine/clone.go deleted file mode 100644 index cf13e8f89..000000000 --- a/packages/browseros/tools/bdev/internal/engine/clone.go +++ /dev/null @@ -1,120 +0,0 @@ -package engine - -import ( - "fmt" - "os" - "path/filepath" - - "bdev/internal/config" - "bdev/internal/git" - "bdev/internal/patch" -) - -type CloneOpts struct { - VerifyBase bool - Clean bool - DryRun bool -} - -func Clone(ctx *config.Context, opts CloneOpts) (*patch.PullResult, error) { - result := &patch.PullResult{} - - // Verify HEAD matches BASE if requested - if opts.VerifyBase { - head, err := git.RevParse(ctx.ChromiumDir, "HEAD") - if err != nil { - return nil, fmt.Errorf("clone: getting HEAD: %w", err) - } - base, err := git.RevParse(ctx.ChromiumDir, ctx.BaseCommit) - if err != nil { - return nil, fmt.Errorf("clone: resolving BASE_COMMIT %s: %w", ctx.BaseCommit, err) - } - if head != base { - return nil, fmt.Errorf("clone: HEAD (%s) does not match BASE_COMMIT (%s) — use --verify-base=false to skip", head[:12], base[:12]) - } - } - - // Clean: reset all modified files to base before applying - if opts.Clean && !opts.DryRun { - nameStatus, err := git.DiffNameStatus(ctx.ChromiumDir, ctx.BaseCommit) - if err != nil { - return nil, fmt.Errorf("clone: discovering local changes: %w", err) - } - - if len(nameStatus) > 0 { - var checkoutFiles []string - for path := range nameStatus { - if git.FileExistsInCommit(ctx.ChromiumDir, ctx.BaseCommit, path) { - checkoutFiles = append(checkoutFiles, path) - } else { - // File doesn't exist in base — remove it - _ = os.Remove(filepath.Join(ctx.ChromiumDir, path)) - } - } - if len(checkoutFiles) > 0 { - if err := git.CheckoutFiles(ctx.ChromiumDir, ctx.BaseCommit, checkoutFiles); err != nil { - return nil, fmt.Errorf("clone: resetting to base: %w", err) - } - } - } - } - - // Read all patches from repo - repoPatchSet, err := patch.ReadPatchSet(ctx.PatchesDir) - if err != nil { - return nil, fmt.Errorf("clone: reading patches: %w", err) - } - - if opts.DryRun { - for path, fp := range repoPatchSet.Patches { - if fp.Op == patch.OpDeleted { - result.Deleted = append(result.Deleted, path) - } else { - result.Applied = append(result.Applied, path) - } - } - return result, nil - } - - // Apply all patches - for path, fp := range repoPatchSet.Patches { - switch fp.Op { - case patch.OpDeleted: - target := filepath.Join(ctx.ChromiumDir, path) - if _, err := os.Stat(target); err == nil { - if err := os.Remove(target); err != nil { - return nil, fmt.Errorf("clone: deleting %s: %w", path, err) - } - result.Deleted = append(result.Deleted, path) - } - - case patch.OpBinary: - // Skip binary files with no content - continue - - default: - if fp.Content == nil { - continue - } - // Remove existing file if it's not in BASE (untracked new-file). - // git diff can't see untracked files, so --clean misses them. - if !git.FileExistsInCommit(ctx.ChromiumDir, ctx.BaseCommit, path) { - _ = os.Remove(filepath.Join(ctx.ChromiumDir, path)) - } - patchFile := filepath.Join(ctx.PatchesDir, path) - conflict, err := git.Apply(ctx.ChromiumDir, fp.Content, patchFile) - if err != nil { - return nil, fmt.Errorf("clone: applying %s: %w", path, err) - } - if conflict != nil { - conflict.File = path - conflict.RejectFile = path + ".rej" - result.Conflicts = append(result.Conflicts, *conflict) - } else { - result.Applied = append(result.Applied, path) - } - } - } - - return result, nil -} diff --git a/packages/browseros/tools/bdev/internal/engine/clone_test.go b/packages/browseros/tools/bdev/internal/engine/clone_test.go deleted file mode 100644 index e51a0bdc2..000000000 --- a/packages/browseros/tools/bdev/internal/engine/clone_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package engine - -import ( - "path/filepath" - "strings" - "testing" -) - -func TestCloneCleanAppliesPatchAndResetsLocalChanges(t *testing.T) { - t.Parallel() - - ctx := setupPullFixture(t) - result, err := Clone(ctx, CloneOpts{ - Clean: true, - }) - if err != nil { - t.Fatalf("Clone: %v", err) - } - - if !contains(result.Applied, "foo.txt") { - t.Fatalf("expected foo.txt to be applied, got %#v", result.Applied) - } - - foo := mustRead(t, filepath.Join(ctx.ChromiumDir, "foo.txt")) - if strings.TrimSpace(foo) != "repo-version" { - t.Fatalf("expected foo.txt to match patch repo, got %q", foo) - } - - orphan := mustRead(t, filepath.Join(ctx.ChromiumDir, "orphan.txt")) - if strings.TrimSpace(orphan) != "orphan-base" { - t.Fatalf("expected orphan.txt to be reset during clean clone, got %q", orphan) - } -} - -func TestCloneVerifyBaseRejectsMismatchedHead(t *testing.T) { - t.Parallel() - - ctx := setupPullFixture(t) - writeFile(t, filepath.Join(ctx.ChromiumDir, "post_base.txt"), "new commit\n") - runGit(t, ctx.ChromiumDir, "add", "post_base.txt") - runGit(t, ctx.ChromiumDir, "commit", "-m", "move head") - - _, err := Clone(ctx, CloneOpts{VerifyBase: true}) - if err == nil { - t.Fatalf("expected verify-base failure when HEAD diverges from BASE_COMMIT") - } - if !strings.Contains(err.Error(), "does not match BASE_COMMIT") { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/packages/browseros/tools/bdev/internal/engine/engine_test.go b/packages/browseros/tools/bdev/internal/engine/engine_test.go new file mode 100644 index 000000000..8a8e2bb04 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/engine/engine_test.go @@ -0,0 +1,220 @@ +package engine + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/patch" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace" +) + +func TestAbortRevertsAppliedOpsAndRestoresPendingStash(t *testing.T) { + ctx := context.Background() + workspacePath := initGitRepo(t) + writeFile(t, filepath.Join(workspacePath, "a.txt"), "a\n") + writeFile(t, filepath.Join(workspacePath, "b.txt"), "b\n") + writeFile(t, filepath.Join(workspacePath, "local.txt"), "local\n") + runGit(t, workspacePath, "add", "a.txt", "b.txt", "local.txt") + runGit(t, workspacePath, "commit", "-m", "base") + baseCommit := gitOutput(t, workspacePath, "rev-parse", "HEAD") + + writeFile(t, filepath.Join(workspacePath, "local.txt"), "local changed\n") + runGit(t, workspacePath, "stash", "push", "-m", "test stash", "-u", "--", "local.txt") + stashRef := gitOutput(t, workspacePath, "stash", "list", "-1", "--format=%gd") + if stashRef == "" { + t.Fatalf("expected stash ref") + } + + if err := workspace.SaveState(workspacePath, &workspace.State{ + Version: 1, + Workspace: workspacePath, + BaseCommit: baseCommit, + PendingStash: stashRef, + }); err != nil { + t.Fatalf("SaveState: %v", err) + } + + writeFile(t, filepath.Join(workspacePath, "a.txt"), "applied\n") + writeFile(t, filepath.Join(workspacePath, "b.txt"), "conflict\n") + if err := resolve.Save(workspacePath, &resolve.State{ + Workspace: workspacePath, + RepoRoot: workspacePath, + BaseCommit: baseCommit, + Current: 1, + Operations: []resolve.Operation{ + {ChromiumPath: "a.txt", PatchRel: "a.txt", Op: patch.OpModify}, + {ChromiumPath: "b.txt", PatchRel: "b.txt", Op: patch.OpModify}, + }, + }); err != nil { + t.Fatalf("resolve.Save: %v", err) + } + + if err := Abort(ctx, workspace.Entry{Name: "ws", Path: workspacePath}); err != nil { + t.Fatalf("Abort: %v", err) + } + + assertFile(t, filepath.Join(workspacePath, "a.txt"), "a\n") + assertFile(t, filepath.Join(workspacePath, "b.txt"), "b\n") + assertFile(t, filepath.Join(workspacePath, "local.txt"), "local changed\n") + if resolve.Exists(workspacePath) { + t.Fatalf("expected resolve state to be removed") + } + state, err := workspace.LoadState(workspacePath) + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if state.PendingStash != "" { + t.Fatalf("expected pending stash cleared, got %q", state.PendingStash) + } +} + +func TestPublishReturnsHelpfulErrorWhenNothingChanged(t *testing.T) { + ctx := context.Background() + repoRoot := initGitRepo(t) + writeFile(t, filepath.Join(repoRoot, "BASE_COMMIT"), "base123\n") + writeFile(t, filepath.Join(repoRoot, "chromium_patches", ".gitkeep"), "") + runGit(t, repoRoot, "add", "BASE_COMMIT", "chromium_patches/.gitkeep") + runGit(t, repoRoot, "commit", "-m", "repo init") + + repoInfo, err := repo.Load(repoRoot) + if err != nil { + t.Fatalf("repo.Load: %v", err) + } + if _, err := Publish(ctx, repoInfo, "", ""); err == nil || !strings.Contains(err.Error(), "nothing to publish") { + t.Fatalf("expected helpful no-op error, got %v", err) + } +} + +func TestOperationsFromChangesNormalizesOldPath(t *testing.T) { + ops := operationsFromChanges(nil, []git.FileChange{{ + Status: "R", + Path: "chromium_patches/chrome/new.cc", + OldPath: "chromium_patches/chrome/old.cc", + }}, nil) + + if len(ops) != 1 { + t.Fatalf("expected 1 operation, got %d", len(ops)) + } + if ops[0].ChromiumPath != "chrome/new.cc" { + t.Fatalf("unexpected chromium path: %q", ops[0].ChromiumPath) + } + if ops[0].OldPath != "chrome/old.cc" { + t.Fatalf("unexpected old path: %q", ops[0].OldPath) + } +} + +func TestSyncClearsPendingStashAfterSuccessfulNonRebaseRun(t *testing.T) { + ctx := context.Background() + workspacePath := initGitRepo(t) + writeFile(t, filepath.Join(workspacePath, "chrome", "browser.cc"), "base\n") + runGit(t, workspacePath, "add", "chrome/browser.cc") + runGit(t, workspacePath, "commit", "-m", "workspace base") + baseCommit := gitOutput(t, workspacePath, "rev-parse", "HEAD") + + remoteRepo := t.TempDir() + runGit(t, remoteRepo, "init", "--bare") + + repoRoot := initGitRepo(t) + if err := os.MkdirAll(filepath.Join(repoRoot, "chromium_patches"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + writeFile(t, filepath.Join(repoRoot, "BASE_COMMIT"), baseCommit+"\n") + runGit(t, repoRoot, "add", "BASE_COMMIT") + runGit(t, repoRoot, "commit", "-m", "patch repo init") + runGit(t, repoRoot, "remote", "add", "origin", remoteRepo) + runGit(t, repoRoot, "push", "-u", "origin", "HEAD") + repoHead := gitOutput(t, repoRoot, "rev-parse", "HEAD") + + repoInfo, err := repo.Load(repoRoot) + if err != nil { + t.Fatalf("repo.Load: %v", err) + } + if err := workspace.SaveState(workspacePath, &workspace.State{ + Version: 1, + Workspace: workspacePath, + BaseCommit: baseCommit, + LastSyncRev: repoHead, + PendingStash: "stash@{42}", + }); err != nil { + t.Fatalf("SaveState: %v", err) + } + + result, err := Sync(ctx, SyncOptions{ + Workspace: workspace.Entry{Name: "ws", Path: workspacePath}, + Repo: repoInfo, + Remote: "origin", + Rebase: false, + }) + if err != nil { + t.Fatalf("Sync: %v", err) + } + if result.StashRef != "" { + t.Fatalf("expected no new stash ref, got %q", result.StashRef) + } + + state, err := workspace.LoadState(workspacePath) + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if state.PendingStash != "" { + t.Fatalf("expected pending stash to be cleared, got %q", state.PendingStash) + } +} + +func initGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + runGit(t, dir, "init") + runGit(t, dir, "config", "user.name", "Test User") + runGit(t, dir, "config", "user.email", "test@example.com") + return dir +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, string(output)) + } +} + +func gitOutput(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, string(output)) + } + return strings.TrimSpace(string(output)) +} + +func writeFile(t *testing.T, path string, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} + +func assertFile(t *testing.T, path string, want string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile %s: %v", path, err) + } + if string(data) != want { + t.Fatalf("unexpected file contents for %s: got %q want %q", path, string(data), want) + } +} diff --git a/packages/browseros/tools/bdev/internal/engine/extract.go b/packages/browseros/tools/bdev/internal/engine/extract.go new file mode 100644 index 000000000..29eb7bbc7 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/engine/extract.go @@ -0,0 +1,119 @@ +package engine + +import ( + "context" + "time" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/patch" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace" +) + +type ExtractOptions struct { + Workspace workspace.Entry + Repo *repo.Info + Commit string + RangeStart string + RangeEnd string + Squash bool + Base string + Filters []string +} + +type ExtractResult struct { + Workspace string `json:"workspace"` + Mode string `json:"mode"` + BaseCommit string `json:"base_commit"` + Written []string `json:"written"` + Deleted []string `json:"deleted"` +} + +func Extract(ctx context.Context, opts ExtractOptions) (*ExtractResult, error) { + base := opts.Base + if base == "" { + base = opts.Repo.BaseCommit + } + var ( + set patch.PatchSet + scope []string + err error + mode string + ) + switch { + case opts.Commit != "": + mode = "commit" + set, err = patch.BuildCommitPatchSet(ctx, opts.Workspace.Path, opts.Commit, opts.Base, opts.Filters) + if err == nil { + if opts.Base != "" { + changes, err := git.DiffTreeNameStatus(ctx, opts.Workspace.Path, opts.Commit, opts.Filters) + if err != nil { + return nil, err + } + scope = changedScope(changes) + } else { + scope = patch.ScopeFromSet(set) + } + } + case opts.RangeStart != "" && opts.RangeEnd != "": + mode = "range" + set, err = patch.BuildRangePatchSet(ctx, opts.Workspace.Path, opts.RangeStart, opts.RangeEnd, opts.Base, opts.Squash, opts.Filters) + if err == nil { + if opts.Base != "" || opts.Squash { + changes, err := git.DiffNameStatusBetween(ctx, opts.Workspace.Path, opts.RangeStart, opts.RangeEnd, opts.Filters) + if err != nil { + return nil, err + } + scope = changedScope(changes) + } else { + scope = patch.ScopeFromSet(set) + } + } + default: + mode = "working-tree" + set, err = patch.BuildWorkingTreePatchSet(ctx, opts.Workspace.Path, base, opts.Filters) + if err == nil && len(opts.Filters) > 0 { + scope = opts.Filters + } + } + if err != nil { + return nil, err + } + written, deleted, err := patch.WriteRepoPatchSet(opts.Repo.PatchesDir, set, scope) + if err != nil { + return nil, err + } + state, err := workspace.LoadState(opts.Workspace.Path) + if err != nil { + return nil, err + } + head, err := git.HeadRev(ctx, opts.Workspace.Path) + if err != nil { + return nil, err + } + state.BaseCommit = opts.Repo.BaseCommit + state.LastExtractRev = head + state.LastExtractAt = time.Now().UTC() + if err := workspace.SaveState(opts.Workspace.Path, state); err != nil { + return nil, err + } + return &ExtractResult{ + Workspace: opts.Workspace.Name, + Mode: mode, + BaseCommit: base, + Written: written, + Deleted: deleted, + }, nil +} + +func changedScope(changes []git.FileChange) []string { + scope := make([]string, 0, len(changes)) + for _, change := range changes { + rel := patch.NormalizeChromiumPath(change.Path) + if patch.IsInternalPath(rel) { + continue + } + scope = append(scope, rel) + } + return scope +} diff --git a/packages/browseros/tools/bdev/internal/engine/publish.go b/packages/browseros/tools/bdev/internal/engine/publish.go new file mode 100644 index 000000000..b1faaf2d3 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/engine/publish.go @@ -0,0 +1,45 @@ +package engine + +import ( + "context" + "fmt" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo" +) + +type PublishResult struct { + Remote string `json:"remote"` + Branch string `json:"branch"` + Message string `json:"message"` +} + +func Publish(ctx context.Context, repoInfo *repo.Info, remote string, message string) (*PublishResult, error) { + if remote == "" { + remote = "origin" + } + if message == "" { + message = "chore: update chromium patches" + } + dirty, err := git.IsDirtyPaths(ctx, repoInfo.Root, []string{"chromium_patches"}) + if err != nil { + return nil, err + } + if !dirty { + return nil, fmt.Errorf("nothing to publish: chromium_patches has no uncommitted changes") + } + if err := git.AddPaths(ctx, repoInfo.Root, []string{"chromium_patches"}); err != nil { + return nil, err + } + if err := git.Commit(ctx, repoInfo.Root, message); err != nil { + return nil, err + } + branch, err := git.CurrentBranch(ctx, repoInfo.Root) + if err != nil { + return nil, err + } + if err := git.Push(ctx, repoInfo.Root, remote, branch); err != nil { + return nil, err + } + return &PublishResult{Remote: remote, Branch: branch, Message: message}, nil +} diff --git a/packages/browseros/tools/bdev/internal/engine/pull.go b/packages/browseros/tools/bdev/internal/engine/pull.go deleted file mode 100644 index a404bd936..000000000 --- a/packages/browseros/tools/bdev/internal/engine/pull.go +++ /dev/null @@ -1,345 +0,0 @@ -package engine - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "bdev/internal/config" - "bdev/internal/git" - "bdev/internal/patch" -) - -type PullOpts struct { - DryRun bool - Files []string - KeepLocalOnly bool -} - -func Pull(ctx *config.Context, opts PullOpts) (*patch.PullResult, error) { - repoPatchSet, err := patch.ReadPatchSet(ctx.PatchesDir) - if err != nil { - return nil, fmt.Errorf("pull: reading repo patches: %w", err) - } - - repoHead, err := git.HeadRev(ctx.PatchesRepo) - if err != nil { - return nil, fmt.Errorf("pull: reading patches repo HEAD: %w", err) - } - - incrementalPaths, shouldUseIncremental, err := resolveIncrementalPaths(ctx, repoHead, opts.Files) - if err != nil { - return nil, fmt.Errorf("pull: resolving incremental scope: %w", err) - } - - if shouldUseIncremental { - result, err := incrementalPull(ctx, repoPatchSet, incrementalPaths, opts.DryRun) - if err != nil { - return nil, err - } - sortPullResult(result) - return result, nil - } - - result, err := fullPull(ctx, repoPatchSet, opts) - if err != nil { - return nil, err - } - sortPullResult(result) - return result, nil -} - -func resolveIncrementalPaths(ctx *config.Context, repoHead string, filesFilter []string) ([]string, bool, error) { - if len(filesFilter) > 0 { - return nil, false, nil - } - - if ctx.State == nil || ctx.State.LastPull == nil { - return nil, false, nil - } - - lastPull := ctx.State.LastPull - if strings.TrimSpace(lastPull.PatchesRepoRev) == "" { - return nil, false, nil - } - - if lastPull.BaseCommit != ctx.BaseCommit { - return nil, false, nil - } - - if !git.CommitExists(ctx.PatchesRepo, lastPull.PatchesRepoRev) { - return nil, false, nil - } - - if lastPull.PatchesRepoRev == repoHead { - return []string{}, true, nil - } - - repoPaths, err := git.DiffChangedPathsBetween( - ctx.PatchesRepo, - lastPull.PatchesRepoRev, - repoHead, - "chromium_patches", - ) - if err != nil { - return nil, false, err - } - - seen := make(map[string]bool) - for _, repoPath := range repoPaths { - chromiumPath, ok := normalizeRepoPatchPath(repoPath) - if !ok { - continue - } - seen[chromiumPath] = true - } - - paths := make([]string, 0, len(seen)) - for p := range seen { - paths = append(paths, p) - } - sort.Strings(paths) - return paths, true, nil -} - -func normalizeRepoPatchPath(repoPath string) (string, bool) { - p := filepath.ToSlash(strings.TrimSpace(repoPath)) - if !strings.HasPrefix(p, "chromium_patches/") { - return "", false - } - - chromiumPath := strings.TrimPrefix(p, "chromium_patches/") - chromiumPath = strings.TrimSuffix(chromiumPath, ".deleted") - chromiumPath = strings.TrimSuffix(chromiumPath, ".binary") - chromiumPath = strings.TrimSuffix(chromiumPath, ".rename") - if chromiumPath == "" { - return "", false - } - - return chromiumPath, true -} - -func incrementalPull( - ctx *config.Context, - repoPatchSet *patch.PatchSet, - paths []string, - dryRun bool, -) (*patch.PullResult, error) { - result := &patch.PullResult{} - - for _, path := range paths { - repoPatch, exists := repoPatchSet.Patches[path] - if !exists { - if !dryRun { - if err := resetPathToBase(ctx, path); err != nil { - return nil, fmt.Errorf("pull: reverting removed patch %s: %w", path, err) - } - } - result.Reverted = append(result.Reverted, path) - continue - } - - switch repoPatch.Op { - case patch.OpDeleted: - if !dryRun { - if err := deletePath(ctx, path); err != nil { - return nil, err - } - } - result.Deleted = append(result.Deleted, path) - case patch.OpBinary: - result.Skipped = append(result.Skipped, path) - default: - if !dryRun { - if err := resetPathToBase(ctx, path); err != nil { - return nil, fmt.Errorf("pull: resetting %s to base: %w", path, err) - } - if err := applyRepoPatch(ctx, repoPatch, path, result); err != nil { - return nil, err - } - } else { - result.Applied = append(result.Applied, path) - } - } - } - - return result, nil -} - -func fullPull(ctx *config.Context, repoPatchSet *patch.PatchSet, opts PullOpts) (*patch.PullResult, error) { - result := &patch.PullResult{} - - diffOutput, err := git.DiffFull(ctx.ChromiumDir, ctx.BaseCommit) - if err != nil { - return nil, fmt.Errorf("pull: reading local diffs: %w", err) - } - - localPatchSet, err := patch.ParseUnifiedDiff(diffOutput) - if err != nil { - return nil, fmt.Errorf("pull: parsing local diffs: %w", err) - } - - delta := patch.Compare(localPatchSet, repoPatchSet) - if len(opts.Files) > 0 { - delta = filterDelta(delta, opts.Files) - } - - if opts.DryRun { - result.Applied = append(delta.NeedsUpdate, delta.NeedsApply...) - result.Skipped = delta.UpToDate - result.Deleted = delta.Deleted - if opts.KeepLocalOnly { - result.LocalOnly = delta.Orphaned - } else { - result.Reverted = delta.Orphaned - } - return result, nil - } - - filesToReset := make([]string, 0, len(delta.NeedsUpdate)+len(delta.Orphaned)) - filesToReset = append(filesToReset, delta.NeedsUpdate...) - if !opts.KeepLocalOnly { - filesToReset = append(filesToReset, delta.Orphaned...) - } - for _, path := range filesToReset { - if err := resetPathToBase(ctx, path); err != nil { - return nil, fmt.Errorf("pull: resetting %s to base: %w", path, err) - } - } - if opts.KeepLocalOnly { - result.LocalOnly = append(result.LocalOnly, delta.Orphaned...) - } else { - result.Reverted = append(result.Reverted, delta.Orphaned...) - } - - filesToApply := make([]string, 0, len(delta.NeedsUpdate)+len(delta.NeedsApply)) - filesToApply = append(filesToApply, delta.NeedsUpdate...) - filesToApply = append(filesToApply, delta.NeedsApply...) - for _, path := range filesToApply { - repoPatch, ok := repoPatchSet.Patches[path] - if !ok || repoPatch.Op == patch.OpDeleted || repoPatch.Op == patch.OpBinary { - continue - } - if err := applyRepoPatch(ctx, repoPatch, path, result); err != nil { - return nil, err - } - } - - for _, path := range delta.Deleted { - if err := deletePath(ctx, path); err != nil { - return nil, err - } - result.Deleted = append(result.Deleted, path) - } - - result.Skipped = delta.UpToDate - return result, nil -} - -func applyRepoPatch( - ctx *config.Context, - repoPatch *patch.FilePatch, - path string, - result *patch.PullResult, -) error { - patchContent := repoPatch.Content - patchFile := filepath.Join(ctx.PatchesDir, path) - - if len(patchContent) == 0 { - onDiskContent, err := os.ReadFile(patchFile) - if err == nil { - patchContent = onDiskContent - } - } - if len(patchContent) == 0 { - result.Skipped = append(result.Skipped, path) - return nil - } - - if !git.FileExistsInCommit(ctx.ChromiumDir, ctx.BaseCommit, path) { - _ = os.Remove(filepath.Join(ctx.ChromiumDir, path)) - } - - conflict, err := git.Apply(ctx.ChromiumDir, patchContent, patchFile) - if err != nil { - return fmt.Errorf("pull: applying %s: %w", path, err) - } - - if conflict != nil { - conflict.File = path - conflict.RejectFile = path + ".rej" - result.Conflicts = append(result.Conflicts, *conflict) - } else { - result.Applied = append(result.Applied, path) - } - - return nil -} - -func resetPathToBase(ctx *config.Context, chromiumPath string) error { - if git.FileExistsInCommit(ctx.ChromiumDir, ctx.BaseCommit, chromiumPath) { - return git.CheckoutFiles(ctx.ChromiumDir, ctx.BaseCommit, []string{chromiumPath}) - } - - target := filepath.Join(ctx.ChromiumDir, chromiumPath) - if err := os.Remove(target); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -func deletePath(ctx *config.Context, chromiumPath string) error { - target := filepath.Join(ctx.ChromiumDir, chromiumPath) - if err := os.Remove(target); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("pull: deleting %s: %w", chromiumPath, err) - } - return nil -} - -func filterDelta(d *patch.Delta, files []string) *patch.Delta { - fileSet := make(map[string]bool) - for _, f := range files { - fileSet[f] = true - } - - filtered := &patch.Delta{} - for _, f := range d.NeedsUpdate { - if fileSet[f] { - filtered.NeedsUpdate = append(filtered.NeedsUpdate, f) - } - } - for _, f := range d.NeedsApply { - if fileSet[f] { - filtered.NeedsApply = append(filtered.NeedsApply, f) - } - } - for _, f := range d.UpToDate { - if fileSet[f] { - filtered.UpToDate = append(filtered.UpToDate, f) - } - } - for _, f := range d.Orphaned { - if fileSet[f] { - filtered.Orphaned = append(filtered.Orphaned, f) - } - } - for _, f := range d.Deleted { - if fileSet[f] { - filtered.Deleted = append(filtered.Deleted, f) - } - } - return filtered -} - -func sortPullResult(result *patch.PullResult) { - sort.Strings(result.Applied) - sort.Strings(result.Skipped) - sort.Strings(result.Reverted) - sort.Strings(result.LocalOnly) - sort.Strings(result.Deleted) - sort.Slice(result.Conflicts, func(i, j int) bool { - return result.Conflicts[i].File < result.Conflicts[j].File - }) -} diff --git a/packages/browseros/tools/bdev/internal/engine/pull_test.go b/packages/browseros/tools/bdev/internal/engine/pull_test.go deleted file mode 100644 index 589d9d5eb..000000000 --- a/packages/browseros/tools/bdev/internal/engine/pull_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package engine - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "bdev/internal/config" -) - -func TestPullKeepsLocalOnlyFilesByDefault(t *testing.T) { - t.Parallel() - - ctx := setupPullFixture(t) - result, err := Pull(ctx, PullOpts{KeepLocalOnly: true}) - if err != nil { - t.Fatalf("Pull: %v", err) - } - - if !contains(result.Applied, "foo.txt") { - t.Fatalf("expected foo.txt to be applied, got %#v", result.Applied) - } - if !contains(result.LocalOnly, "orphan.txt") { - t.Fatalf("expected orphan.txt in local-only list, got %#v", result.LocalOnly) - } - if contains(result.Reverted, "orphan.txt") { - t.Fatalf("orphan.txt should not be reverted when KeepLocalOnly=true") - } - - fooContent := mustRead(t, filepath.Join(ctx.ChromiumDir, "foo.txt")) - if strings.TrimSpace(fooContent) != "repo-version" { - t.Fatalf("unexpected foo.txt content: %q", fooContent) - } - - orphanContent := mustRead(t, filepath.Join(ctx.ChromiumDir, "orphan.txt")) - if strings.TrimSpace(orphanContent) != "local-only-change" { - t.Fatalf("orphan.txt should be preserved, got %q", orphanContent) - } -} - -func TestPullRevertsLocalOnlyWhenDisabled(t *testing.T) { - t.Parallel() - - ctx := setupPullFixture(t) - result, err := Pull(ctx, PullOpts{KeepLocalOnly: false}) - if err != nil { - t.Fatalf("Pull: %v", err) - } - - if !contains(result.Reverted, "orphan.txt") { - t.Fatalf("expected orphan.txt to be reverted, got %#v", result.Reverted) - } - if contains(result.LocalOnly, "orphan.txt") { - t.Fatalf("orphan.txt should not be local-only when KeepLocalOnly=false") - } - - orphanContent := mustRead(t, filepath.Join(ctx.ChromiumDir, "orphan.txt")) - if strings.TrimSpace(orphanContent) != "orphan-base" { - t.Fatalf("orphan.txt should be reset to base, got %q", orphanContent) - } -} - -func setupPullFixture(t *testing.T) *config.Context { - t.Helper() - - root := t.TempDir() - chromiumDir := filepath.Join(root, "chromium") - patchesRepo := filepath.Join(root, "patches") - - if err := os.MkdirAll(chromiumDir, 0o755); err != nil { - t.Fatalf("mkdir chromium: %v", err) - } - if err := os.MkdirAll(patchesRepo, 0o755); err != nil { - t.Fatalf("mkdir patches: %v", err) - } - - initRepo(t, chromiumDir) - writeFile(t, filepath.Join(chromiumDir, "foo.txt"), "base\n") - writeFile(t, filepath.Join(chromiumDir, "orphan.txt"), "orphan-base\n") - runGit(t, chromiumDir, "add", "foo.txt", "orphan.txt") - runGit(t, chromiumDir, "commit", "-m", "base") - baseCommit := strings.TrimSpace(runGit(t, chromiumDir, "rev-parse", "HEAD")) - - writeFile(t, filepath.Join(chromiumDir, "foo.txt"), "repo-version\n") - patchDiff := runGit(t, chromiumDir, "diff", "--full-index", baseCommit, "--", "foo.txt") - if strings.TrimSpace(patchDiff) == "" { - t.Fatalf("expected patch diff for foo.txt") - } - runGit(t, chromiumDir, "checkout", baseCommit, "--", "foo.txt") - - writeFile(t, filepath.Join(chromiumDir, "orphan.txt"), "local-only-change\n") - - initRepo(t, patchesRepo) - writeFile(t, filepath.Join(patchesRepo, "BASE_COMMIT"), baseCommit+"\n") - writeFile(t, filepath.Join(patchesRepo, "chromium_patches", "foo.txt"), patchDiff) - runGit(t, patchesRepo, "add", ".") - runGit(t, patchesRepo, "commit", "-m", "seed patch repo") - - return &config.Context{ - Config: &config.Config{Name: "test-checkout", PatchesRepo: patchesRepo}, - State: &config.State{}, - ChromiumDir: chromiumDir, - BrosDir: filepath.Join(chromiumDir, ".bros"), - PatchesRepo: patchesRepo, - PatchesDir: filepath.Join(patchesRepo, "chromium_patches"), - BaseCommit: baseCommit, - } -} - -func initRepo(t *testing.T, dir string) { - t.Helper() - runGit(t, dir, "init") - runGit(t, dir, "config", "user.email", "bdev-test@example.com") - runGit(t, dir, "config", "user.name", "bdev test") -} - -func runGit(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, string(out)) - } - return string(out) -} - -func contains(items []string, target string) bool { - for _, item := range items { - if item == target { - return true - } - } - return false -} - -func writeFile(t *testing.T, path, content string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", path, err) - } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } -} - -func mustRead(t *testing.T, path string) string { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read %s: %v", path, err) - } - return string(data) -} diff --git a/packages/browseros/tools/bdev/internal/engine/push.go b/packages/browseros/tools/bdev/internal/engine/push.go deleted file mode 100644 index 6a09dbb5d..000000000 --- a/packages/browseros/tools/bdev/internal/engine/push.go +++ /dev/null @@ -1,147 +0,0 @@ -package engine - -import ( - "fmt" - "sort" - - "bdev/internal/config" - "bdev/internal/git" - "bdev/internal/patch" -) - -type PushOpts struct { - DryRun bool - Files []string -} - -func Push(ctx *config.Context, opts PushOpts) (*patch.PushResult, error) { - result := &patch.PushResult{} - - // Phase 1: Discover changed files (working tree vs BASE) - nameStatus, err := git.DiffNameStatus(ctx.ChromiumDir, ctx.BaseCommit) - if err != nil { - return nil, fmt.Errorf("push: discovering changes: %w", err) - } - - untracked, err := git.UntrackedFiles(ctx.ChromiumDir) - if err != nil { - return nil, fmt.Errorf("push: discovering untracked files: %w", err) - } - untrackedSet := make(map[string]bool, len(untracked)) - for _, path := range untracked { - untrackedSet[path] = true - if _, exists := nameStatus[path]; !exists { - nameStatus[path] = patch.OpAdded - } - } - - if len(nameStatus) == 0 { - return result, nil - } - - // Filter to requested files if specified - if len(opts.Files) > 0 { - filtered := make(map[string]patch.FileOp) - for _, f := range opts.Files { - if op, ok := nameStatus[f]; ok { - filtered[f] = op - } - } - nameStatus = filtered - } - - if len(nameStatus) == 0 { - return result, nil - } - - // Phase 2: Generate patches - var diffOutput []byte - files := make([]string, 0, len(nameStatus)) - for f := range nameStatus { - files = append(files, f) - } - sort.Strings(files) - - diffOutput, err = git.DiffFiles(ctx.ChromiumDir, ctx.BaseCommit, files) - if err != nil { - return nil, fmt.Errorf("push: generating diffs: %w", err) - } - for _, file := range files { - if !untrackedSet[file] { - continue - } - noIndexDiff, err := git.DiffNoIndexFile(ctx.ChromiumDir, file) - if err != nil { - return nil, fmt.Errorf("push: generating no-index diff for %s: %w", file, err) - } - if len(noIndexDiff) == 0 { - continue - } - if len(diffOutput) > 0 && diffOutput[len(diffOutput)-1] != '\n' { - diffOutput = append(diffOutput, '\n') - } - diffOutput = append(diffOutput, noIndexDiff...) - } - - patchSet, err := patch.ParseUnifiedDiff(diffOutput) - if err != nil { - return nil, fmt.Errorf("push: parsing diffs: %w", err) - } - patchSet.Base = ctx.BaseCommit - - // Merge in deleted files that won't appear in diff output - for path, op := range nameStatus { - if op == patch.OpDeleted { - if _, exists := patchSet.Patches[path]; !exists { - patchSet.Patches[path] = &patch.FilePatch{ - Path: path, - Op: patch.OpDeleted, - } - } - } - } - - // Classify results for reporting - existingPatches := make(map[string]bool) - if existing, err := patch.ReadPatchFiles(ctx.PatchesDir); err == nil { - for p := range existing { - existingPatches[p] = true - } - } - - for path, fp := range patchSet.Patches { - switch fp.Op { - case patch.OpDeleted: - result.Deleted = append(result.Deleted, path) - case patch.OpAdded: - result.Added = append(result.Added, path) - default: - if existingPatches[path] { - result.Modified = append(result.Modified, path) - } else { - result.Added = append(result.Added, path) - } - } - } - - sort.Strings(result.Modified) - sort.Strings(result.Added) - sort.Strings(result.Deleted) - - // Phase 3: Write patches - if err := patch.WritePatchSet(ctx.PatchesDir, patchSet, opts.DryRun); err != nil { - return nil, fmt.Errorf("push: writing patches: %w", err) - } - - // Phase 4: Stale cleanup - if !opts.DryRun && len(opts.Files) == 0 { - stale, err := patch.RemoveStale(ctx.PatchesDir, patchSet, opts.DryRun) - if err != nil { - return nil, fmt.Errorf("push: stale cleanup: %w", err) - } - result.Stale = stale - sort.Strings(result.Stale) - } - - return result, nil -} diff --git a/packages/browseros/tools/bdev/internal/engine/push_test.go b/packages/browseros/tools/bdev/internal/engine/push_test.go deleted file mode 100644 index c6cf52ccc..000000000 --- a/packages/browseros/tools/bdev/internal/engine/push_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package engine - -import ( - "path/filepath" - "strings" - "testing" -) - -func TestPushIncludesUntrackedFiles(t *testing.T) { - t.Parallel() - - ctx := setupPullFixture(t) - if err := resetPathToBase(ctx, "orphan.txt"); err != nil { - t.Fatalf("reset orphan.txt: %v", err) - } - - writeFile(t, filepath.Join(ctx.ChromiumDir, "foo.txt"), "repo-version-v2\n") - writeFile(t, filepath.Join(ctx.ChromiumDir, "new", "file.txt"), "brand-new\n") - - dryRun, err := Push(ctx, PushOpts{DryRun: true}) - if err != nil { - t.Fatalf("Push dry-run: %v", err) - } - if !contains(dryRun.Modified, "foo.txt") { - t.Fatalf("expected foo.txt in modified set, got %#v", dryRun.Modified) - } - if !contains(dryRun.Added, "new/file.txt") { - t.Fatalf("expected new/file.txt in added set, got %#v", dryRun.Added) - } - - result, err := Push(ctx, PushOpts{}) - if err != nil { - t.Fatalf("Push: %v", err) - } - if !contains(result.Added, "new/file.txt") { - t.Fatalf("expected new/file.txt in added result, got %#v", result.Added) - } - - patchContent := mustRead(t, filepath.Join(ctx.PatchesDir, "new", "file.txt")) - if !strings.Contains(patchContent, "diff --git a/new/file.txt b/new/file.txt") { - t.Fatalf("unexpected patch content for untracked file:\n%s", patchContent) - } -} diff --git a/packages/browseros/tools/bdev/internal/engine/status.go b/packages/browseros/tools/bdev/internal/engine/status.go index 37b67ab09..5227e63b2 100644 --- a/packages/browseros/tools/bdev/internal/engine/status.go +++ b/packages/browseros/tools/bdev/internal/engine/status.go @@ -1,62 +1,85 @@ package engine import ( - "fmt" + "context" - "bdev/internal/config" - "bdev/internal/git" - "bdev/internal/patch" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/patch" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/resolve" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace" ) -type StatusResult struct { - CheckoutName string - BaseCommit string - ChromiumVersion string - PatchesRepo string - Ahead int - Behind int - Synced int - AheadFiles []string - BehindFiles []string - SyncedFiles []string +type WorkspaceStatus struct { + Workspace workspace.Entry `json:"workspace"` + RepoHead string `json:"repo_head"` + BaseCommit string `json:"base_commit"` + LastApplyRev string `json:"last_apply_rev,omitempty"` + LastSyncRev string `json:"last_sync_rev,omitempty"` + LastExtractRev string `json:"last_extract_rev,omitempty"` + ActiveResolve bool `json:"active_resolve"` + NeedsApply []string `json:"needs_apply"` + NeedsUpdate []string `json:"needs_update"` + Orphaned []string `json:"orphaned"` + UpToDate []string `json:"up_to_date"` + SyncState string `json:"sync_state"` } -func Status(ctx *config.Context, showFiles bool) (*StatusResult, error) { - result := &StatusResult{ - CheckoutName: ctx.Config.Name, - BaseCommit: ctx.BaseCommit, - ChromiumVersion: ctx.ChromiumVersion, - PatchesRepo: ctx.PatchesRepo, - } - - // Read repo patches - repoPatchSet, err := patch.ReadPatchSet(ctx.PatchesDir) +func InspectWorkspace(ctx context.Context, ws workspace.Entry, repoInfo *repo.Info) (*WorkspaceStatus, error) { + head, err := git.HeadRev(ctx, repoInfo.Root) if err != nil { - return nil, fmt.Errorf("status: reading repo patches: %w", err) + return nil, err } - - // Read local state (working tree vs BASE) - diffOutput, err := git.DiffFull(ctx.ChromiumDir, ctx.BaseCommit) + state, err := workspace.LoadState(ws.Path) if err != nil { - return nil, fmt.Errorf("status: reading local diffs: %w", err) + return nil, err } - - localPatchSet, err := patch.ParseUnifiedDiff(diffOutput) + repoSet, err := patch.LoadRepoPatchSet(repoInfo.PatchesDir, nil) if err != nil { - return nil, fmt.Errorf("status: parsing local diffs: %w", err) + return nil, err } - - delta := patch.Compare(localPatchSet, repoPatchSet) - - result.Ahead = len(delta.Orphaned) - result.Behind = len(delta.NeedsApply) + len(delta.NeedsUpdate) - result.Synced = len(delta.UpToDate) - - if showFiles { - result.AheadFiles = delta.Orphaned - result.BehindFiles = append(delta.NeedsApply, delta.NeedsUpdate...) - result.SyncedFiles = delta.UpToDate + localSet, err := patch.BuildWorkingTreePatchSet(ctx, ws.Path, repoInfo.BaseCommit, nil) + if err != nil { + return nil, err + } + status := &WorkspaceStatus{ + Workspace: ws, + RepoHead: head, + BaseCommit: repoInfo.BaseCommit, + LastApplyRev: state.LastApplyRev, + LastSyncRev: state.LastSyncRev, + LastExtractRev: state.LastExtractRev, + ActiveResolve: resolve.Exists(ws.Path), + } + for _, delta := range patch.Compare(repoSet, localSet) { + switch delta.Kind { + case patch.NeedsApply: + status.NeedsApply = append(status.NeedsApply, delta.Path) + case patch.NeedsUpdate: + status.NeedsUpdate = append(status.NeedsUpdate, delta.Path) + case patch.Orphaned: + status.Orphaned = append(status.Orphaned, delta.Path) + case patch.UpToDate: + status.UpToDate = append(status.UpToDate, delta.Path) + } + } + status.SyncState = inferSyncState(status) + return status, nil +} + +func inferSyncState(status *WorkspaceStatus) string { + switch { + case status.ActiveResolve: + return "conflicted" + case status.LastSyncRev == "": + return "never-synced" + case status.LastSyncRev != status.RepoHead: + return "needs-sync" + case len(status.NeedsApply) > 0: + return "drifted" + case len(status.NeedsUpdate) > 0 || len(status.Orphaned) > 0: + return "local-changes" + default: + return "synced" } - - return result, nil } diff --git a/packages/browseros/tools/bdev/internal/engine/status_test.go b/packages/browseros/tools/bdev/internal/engine/status_test.go deleted file mode 100644 index 5b1865e3c..000000000 --- a/packages/browseros/tools/bdev/internal/engine/status_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package engine - -import "testing" - -func TestStatusReflectsAheadBehindAndSynced(t *testing.T) { - t.Parallel() - - ctx := setupPullFixture(t) - - before, err := Status(ctx, true) - if err != nil { - t.Fatalf("Status before pull: %v", err) - } - if before.Ahead != 1 || before.Behind != 1 || before.Synced != 0 { - t.Fatalf("unexpected status before pull: ahead=%d behind=%d synced=%d", before.Ahead, before.Behind, before.Synced) - } - if !contains(before.AheadFiles, "orphan.txt") { - t.Fatalf("expected orphan.txt in ahead files, got %#v", before.AheadFiles) - } - if !contains(before.BehindFiles, "foo.txt") { - t.Fatalf("expected foo.txt in behind files, got %#v", before.BehindFiles) - } -} diff --git a/packages/browseros/tools/bdev/internal/engine/sync.go b/packages/browseros/tools/bdev/internal/engine/sync.go new file mode 100644 index 000000000..8e3540271 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/engine/sync.go @@ -0,0 +1,130 @@ +package engine + +import ( + "context" + "fmt" + "time" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/repo" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace" +) + +type SyncOptions struct { + Workspace workspace.Entry + Repo *repo.Info + Remote string + Rebase bool +} + +type SyncResult struct { + Workspace string `json:"workspace"` + Remote string `json:"remote"` + RepoHead string `json:"repo_head"` + StashRef string `json:"stash_ref,omitempty"` + Rebased bool `json:"rebased"` + Fallback bool `json:"fallback"` + Applied []string `json:"applied,omitempty"` + Conflicts []string `json:"conflicts,omitempty"` +} + +func Sync(ctx context.Context, opts SyncOptions) (*SyncResult, error) { + if opts.Remote == "" { + opts.Remote = "origin" + } + dirty, err := git.IsDirty(ctx, opts.Repo.Root) + if err != nil { + return nil, err + } + if dirty { + return nil, fmt.Errorf("patches repo has uncommitted changes; commit or stash them before syncing") + } + branch, err := git.CurrentBranch(ctx, opts.Repo.Root) + if err != nil { + return nil, err + } + if err := git.PullRebase(ctx, opts.Repo.Root, opts.Remote, branch); err != nil { + return nil, err + } + head, err := git.HeadRev(ctx, opts.Repo.Root) + if err != nil { + return nil, err + } + state, err := workspace.LoadState(opts.Workspace.Path) + if err != nil { + return nil, err + } + result := &SyncResult{ + Workspace: opts.Workspace.Name, + Remote: opts.Remote, + RepoHead: head, + Rebased: opts.Rebase, + } + status, err := InspectWorkspace(ctx, opts.Workspace, opts.Repo) + if err != nil { + return nil, err + } + divergent := append([]string{}, status.NeedsUpdate...) + divergent = append(divergent, status.Orphaned...) + if len(divergent) > 0 { + stashRef, err := git.StashPush(ctx, opts.Workspace.Path, "bdev sync stash", true, divergent) + if err != nil { + return nil, err + } + result.StashRef = stashRef + state.PendingStash = stashRef + if err := workspace.SaveState(opts.Workspace.Path, state); err != nil { + return nil, err + } + } + if state.LastSyncRev == "" || state.BaseCommit != "" && state.BaseCommit != opts.Repo.BaseCommit { + result.Fallback = true + applyResult, err := Apply(ctx, ApplyOptions{ + Workspace: opts.Workspace, + Repo: opts.Repo, + Reset: true, + Mode: "sync-reset", + }) + if err != nil { + return nil, err + } + result.Applied = applyResult.Applied + if len(applyResult.Conflicts) > 0 { + for _, conflict := range applyResult.Conflicts { + result.Conflicts = append(result.Conflicts, conflict.ChromiumPath) + } + return result, nil + } + } else { + applyResult, err := Apply(ctx, ApplyOptions{ + Workspace: opts.Workspace, + Repo: opts.Repo, + ChangedRef: state.LastSyncRev, + RangeEnd: head, + Mode: "sync", + }) + if err != nil { + return nil, err + } + result.Applied = applyResult.Applied + if len(applyResult.Conflicts) > 0 { + for _, conflict := range applyResult.Conflicts { + result.Conflicts = append(result.Conflicts, conflict.ChromiumPath) + } + return result, nil + } + } + if opts.Rebase && result.StashRef != "" { + if err := git.StashPop(ctx, opts.Workspace.Path, result.StashRef); err != nil { + return nil, err + } + } + state.PendingStash = "" + state.BaseCommit = opts.Repo.BaseCommit + state.LastSyncRev = head + state.LastSyncAt = time.Now().UTC() + if err := workspace.SaveState(opts.Workspace.Path, state); err != nil { + return nil, err + } + return result, nil +} diff --git a/packages/browseros/tools/bdev/internal/git/apply.go b/packages/browseros/tools/bdev/internal/git/apply.go deleted file mode 100644 index f53facd53..000000000 --- a/packages/browseros/tools/bdev/internal/git/apply.go +++ /dev/null @@ -1,123 +0,0 @@ -package git - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "regexp" - "strconv" - "strings" - "time" - - "bdev/internal/patch" -) - -var rejHunkRe = regexp.MustCompile(`Applying patch .* with (\d+) hunks?`) -var rejFailRe = regexp.MustCompile(`(\d+) out of (\d+) hunks? FAILED`) - -// Apply tries multiple strategies to apply a patch, falling back to --reject -// only as a last resort. Mirrors the Python CLI fallback chain. -func Apply(dir string, patchContent []byte, patchFile string) (*patch.ConflictInfo, error) { - // Strategy 1: Clean apply - if tryApply(dir, patchContent, "--ignore-whitespace", "--whitespace=nowarn", "-p1") == nil { - return nil, nil - } - - // Strategy 2: Three-way merge - if tryApply(dir, patchContent, "--ignore-whitespace", "--whitespace=nowarn", "-p1", "--3way") == nil { - return nil, nil - } - - // Strategy 3: Whitespace fix - if tryApply(dir, patchContent, "--ignore-whitespace", "--whitespace=fix", "-p1") == nil { - return nil, nil - } - - // Strategy 4: Reject (last resort — partially applies, creates .rej files) - return applyReject(dir, patchContent, patchFile) -} - -// tryApply attempts a git apply with the given flags. Returns nil on success. -func tryApply(dir string, patchContent []byte, flags ...string) error { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - args := append([]string{"apply"}, flags...) - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Dir = dir - cmd.Stdin = bytes.NewReader(patchContent) - - var stderr bytes.Buffer - cmd.Stderr = &stderr - - return cmd.Run() -} - -// applyReject applies a patch with --reject, creating .rej files for failed hunks. -func applyReject(dir string, patchContent []byte, patchFile string) (*patch.ConflictInfo, error) { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "git", "apply", - "--reject", - "--ignore-whitespace", - "--whitespace=nowarn", - "-p1", - ) - cmd.Dir = dir - cmd.Stdin = bytes.NewReader(patchContent) - - var stderr bytes.Buffer - cmd.Stderr = &stderr - - err := cmd.Run() - if err == nil { - return nil, nil - } - - stderrStr := stderr.String() - info := &patch.ConflictInfo{ - PatchFile: patchFile, - Error: strings.TrimSpace(stderrStr), - } - - if m := rejFailRe.FindStringSubmatch(stderrStr); len(m) == 3 { - info.HunksFailed, _ = strconv.Atoi(m[1]) - info.HunksTotal, _ = strconv.Atoi(m[2]) - } else if m := rejHunkRe.FindStringSubmatch(stderrStr); len(m) == 2 { - info.HunksTotal, _ = strconv.Atoi(m[1]) - info.HunksFailed = info.HunksTotal - } - - return info, nil -} - -// ApplyPatchFile applies a patch from a file path using the full fallback chain. -func ApplyPatchFile(dir, patchPath string) (*patch.ConflictInfo, error) { - content, err := os.ReadFile(patchPath) - if err != nil { - return nil, fmt.Errorf("reading patch file %s: %w", patchPath, err) - } - info, err := Apply(dir, content, patchPath) - if info != nil { - info.PatchFile = patchPath - } - return info, err -} - -// ApplyCheck tests if a patch would apply without modifying anything. -func ApplyCheck(dir string, patchContent []byte) error { - cmd := exec.Command("git", "apply", "--check", "-p1") - cmd.Dir = dir - cmd.Stdin = bytes.NewReader(patchContent) - - var stderr bytes.Buffer - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("patch would not apply cleanly: %s", stderr.String()) - } - return nil -} diff --git a/packages/browseros/tools/bdev/internal/git/checkout.go b/packages/browseros/tools/bdev/internal/git/checkout.go deleted file mode 100644 index 7831958a3..000000000 --- a/packages/browseros/tools/bdev/internal/git/checkout.go +++ /dev/null @@ -1,20 +0,0 @@ -package git - -import "fmt" - -// CheckoutFiles resets multiple files to a specific commit. -// Batches into a single git call. -func CheckoutFiles(dir, commit string, files []string) error { - if len(files) == 0 { - return nil - } - - args := []string{"checkout", commit, "--"} - args = append(args, files...) - - _, err := Run(dir, args...) - if err != nil { - return fmt.Errorf("checkout %s -- [%d files]: %w", commit, len(files), err) - } - return nil -} diff --git a/packages/browseros/tools/bdev/internal/git/diff.go b/packages/browseros/tools/bdev/internal/git/diff.go deleted file mode 100644 index 9096f2628..000000000 --- a/packages/browseros/tools/bdev/internal/git/diff.go +++ /dev/null @@ -1,179 +0,0 @@ -package git - -import ( - "bytes" - "context" - "fmt" - "os/exec" - "sort" - "strings" - "time" - - "bdev/internal/patch" -) - -// DiffNameStatus returns file paths mapped to their operation. -// Diffs BASE against the working tree (not HEAD) so uncommitted patch -// applications are visible. -func DiffNameStatus(dir, base string) (map[string]patch.FileOp, error) { - out, err := Run(dir, "diff", "--name-status", "-M", base) - if err != nil { - return nil, fmt.Errorf("diff --name-status %s: %w", base, err) - } - - result := make(map[string]patch.FileOp) - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - fields := strings.Fields(line) - if len(fields) < 2 { - continue - } - - status := fields[0] - path := fields[len(fields)-1] - - switch { - case status == "M": - result[path] = patch.OpModified - case status == "A": - result[path] = patch.OpAdded - case status == "D": - result[path] = patch.OpDeleted - case strings.HasPrefix(status, "R"): - result[path] = patch.OpRenamed - default: - result[path] = patch.OpModified - } - } - - return result, nil -} - -// DiffFull returns the complete unified diff of BASE vs working tree. -func DiffFull(dir, base string) ([]byte, error) { - out, err := RunBytes(dir, "diff", "-M", "--full-index", base) - if err != nil { - return nil, fmt.Errorf("diff %s: %w", base, err) - } - return out, nil -} - -// DiffFile returns the working-tree diff for a single file. -func DiffFile(dir, base, file string) ([]byte, error) { - out, err := RunBytes(dir, "diff", "-M", "--full-index", base, "--", file) - if err != nil { - return nil, fmt.Errorf("diff %s -- %s: %w", base, file, err) - } - return out, nil -} - -// DiffFiles returns working-tree diffs for multiple files in one call. -func DiffFiles(dir, base string, files []string) ([]byte, error) { - args := []string{"diff", "-M", "--full-index", base, "--"} - args = append(args, files...) - out, err := RunBytes(dir, args...) - if err != nil { - return nil, fmt.Errorf("diff %s -- [%d files]: %w", base, len(files), err) - } - return out, nil -} - -// DiffNoIndexFile builds a patch for an untracked file as if it were added from /dev/null. -// git diff --no-index exits with status 1 when files differ; treat that as success. -func DiffNoIndexFile(dir, file string) ([]byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "git", "diff", "--no-index", "--full-index", "/dev/null", file) - cmd.Dir = dir - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - if err == nil { - return stdout.Bytes(), nil - } - - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - return stdout.Bytes(), nil - } - - if ctx.Err() == context.DeadlineExceeded { - return nil, fmt.Errorf("diff --no-index %s: timed out", file) - } - return nil, fmt.Errorf("diff --no-index %s: %w\n%s", file, err, stderr.String()) -} - -// UntrackedFiles returns all untracked files (exclude-standard) in the repo. -func UntrackedFiles(dir string) ([]string, error) { - out, err := Run(dir, "ls-files", "--others", "--exclude-standard") - if err != nil { - return nil, fmt.Errorf("ls-files --others: %w", err) - } - if strings.TrimSpace(out) == "" { - return nil, nil - } - - var files []string - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line != "" { - files = append(files, line) - } - } - sort.Strings(files) - return files, nil -} - -// DiffChangedPathsBetween returns changed paths between two revisions. -// It includes old and new paths for rename/copy records. -func DiffChangedPathsBetween(dir, fromRev, toRev string, pathspec ...string) ([]string, error) { - args := []string{ - "diff", - "--name-status", - "--find-renames", - fmt.Sprintf("%s..%s", fromRev, toRev), - } - if len(pathspec) > 0 { - args = append(args, "--") - args = append(args, pathspec...) - } - - out, err := Run(dir, args...) - if err != nil { - return nil, fmt.Errorf("diff --name-status %s..%s: %w", fromRev, toRev, err) - } - - seen := make(map[string]bool) - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - fields := strings.Split(line, "\t") - if len(fields) < 2 { - continue - } - - for _, p := range fields[1:] { - p = strings.TrimSpace(p) - if p != "" { - seen[p] = true - } - } - } - - paths := make([]string, 0, len(seen)) - for p := range seen { - paths = append(paths, p) - } - sort.Strings(paths) - return paths, nil -} diff --git a/packages/browseros/tools/bdev/internal/git/exec.go b/packages/browseros/tools/bdev/internal/git/exec.go deleted file mode 100644 index 18bb9e977..000000000 --- a/packages/browseros/tools/bdev/internal/git/exec.go +++ /dev/null @@ -1,41 +0,0 @@ -package git - -import ( - "bytes" - "context" - "fmt" - "os/exec" - "strings" - "time" -) - -const defaultTimeout = 120 * time.Second - -func Run(dir string, args ...string) (string, error) { - out, err := RunBytes(dir, args...) - return strings.TrimRight(string(out), "\n"), err -} - -func RunBytes(dir string, args ...string) ([]byte, error) { - return RunWithTimeout(dir, defaultTimeout, args...) -} - -func RunWithTimeout(dir string, timeout time.Duration, args ...string) ([]byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Dir = dir - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - if ctx.Err() == context.DeadlineExceeded { - return nil, fmt.Errorf("git %s: timed out after %s", args[0], timeout) - } - return nil, fmt.Errorf("git %s: %w\n%s", args[0], err, stderr.String()) - } - return stdout.Bytes(), nil -} diff --git a/packages/browseros/tools/bdev/internal/git/git.go b/packages/browseros/tools/bdev/internal/git/git.go new file mode 100644 index 000000000..6e8e93e1a --- /dev/null +++ b/packages/browseros/tools/bdev/internal/git/git.go @@ -0,0 +1,387 @@ +package git + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type Result struct { + Stdout string + Stderr string + Code int +} + +type FileChange struct { + Status string `json:"status"` + Path string `json:"path"` + OldPath string `json:"old_path,omitempty"` +} + +func Run(ctx context.Context, dir string, stdin []byte, args ...string) (Result, error) { + command := exec.CommandContext(ctx, "git", args...) + command.Dir = dir + if stdin != nil { + command.Stdin = bytes.NewReader(stdin) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + err := command.Run() + code := -1 + if command.ProcessState != nil { + code = command.ProcessState.ExitCode() + } + result := Result{ + Stdout: stdout.String(), + Stderr: stderr.String(), + Code: code, + } + if err == nil { + return result, nil + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { + return result, err + } + if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) { + return result, err + } + if command.ProcessState == nil { + return result, err + } + return result, nil +} + +func HeadRev(ctx context.Context, dir string) (string, error) { + result, err := Run(ctx, dir, nil, "rev-parse", "HEAD") + if err != nil { + return "", err + } + if result.Code != 0 { + return "", errors.New(strings.TrimSpace(result.Stderr)) + } + return strings.TrimSpace(result.Stdout), nil +} + +func CurrentBranch(ctx context.Context, dir string) (string, error) { + result, err := Run(ctx, dir, nil, "branch", "--show-current") + if err != nil { + return "", err + } + if result.Code != 0 { + return "", errors.New(strings.TrimSpace(result.Stderr)) + } + return strings.TrimSpace(result.Stdout), nil +} + +func IsDirty(ctx context.Context, dir string) (bool, error) { + return IsDirtyPaths(ctx, dir, nil) +} + +func IsDirtyPaths(ctx context.Context, dir string, pathspecs []string) (bool, error) { + args := []string{"status", "--porcelain"} + if len(pathspecs) > 0 { + args = append(args, "--") + args = append(args, pathspecs...) + } + result, err := Run(ctx, dir, nil, args...) + if err != nil { + return false, err + } + if result.Code != 0 { + return false, errors.New(strings.TrimSpace(result.Stderr)) + } + return strings.TrimSpace(result.Stdout) != "", nil +} + +func CommitExists(ctx context.Context, dir string, ref string) (bool, error) { + result, err := Run(ctx, dir, nil, "rev-parse", "--verify", ref+"^{commit}") + if err != nil { + return false, err + } + return result.Code == 0, nil +} + +func FileExistsAtCommit(ctx context.Context, dir string, ref string, rel string) (bool, error) { + result, err := Run(ctx, dir, nil, "cat-file", "-e", fmt.Sprintf("%s:%s", ref, rel)) + if err != nil { + return false, err + } + return result.Code == 0, nil +} + +func ShowFile(ctx context.Context, dir string, ref string, rel string) ([]byte, error) { + result, err := Run(ctx, dir, nil, "show", fmt.Sprintf("%s:%s", ref, rel)) + if err != nil { + return nil, err + } + if result.Code != 0 { + return nil, errors.New(strings.TrimSpace(result.Stderr)) + } + return []byte(result.Stdout), nil +} + +func CheckoutFiles(ctx context.Context, dir string, ref string, paths []string) error { + if len(paths) == 0 { + return nil + } + args := []string{"checkout", ref, "--"} + args = append(args, paths...) + result, err := Run(ctx, dir, nil, args...) + if err != nil { + return err + } + if result.Code != 0 { + return errors.New(strings.TrimSpace(result.Stderr)) + } + return nil +} + +func ResetPathToCommit(ctx context.Context, dir string, ref string, rel string) error { + exists, err := FileExistsAtCommit(ctx, dir, ref, rel) + if err != nil { + return err + } + target := filepath.Join(dir, filepath.FromSlash(rel)) + if exists { + return CheckoutFiles(ctx, dir, ref, []string{rel}) + } + return os.RemoveAll(target) +} + +func DiffText(ctx context.Context, dir string, args ...string) (string, error) { + result, err := Run(ctx, dir, nil, append([]string{"diff", "--binary", "-M"}, args...)...) + if err != nil { + return "", err + } + if result.Code != 0 { + return "", errors.New(strings.TrimSpace(result.Stderr)) + } + return result.Stdout, nil +} + +func DiffNoIndex(ctx context.Context, dir string, path string) (string, error) { + result, err := Run(ctx, dir, nil, "diff", "--binary", "--no-index", "--", "/dev/null", path) + if err != nil { + return "", err + } + if result.Code != 0 && result.Code != 1 { + return "", errors.New(strings.TrimSpace(result.Stderr)) + } + return result.Stdout, nil +} + +func ListUntracked(ctx context.Context, dir string, pathspecs []string) ([]string, error) { + args := []string{"ls-files", "--others", "--exclude-standard"} + if len(pathspecs) > 0 { + args = append(args, "--") + args = append(args, pathspecs...) + } + result, err := Run(ctx, dir, nil, args...) + if err != nil { + return nil, err + } + if result.Code != 0 { + return nil, errors.New(strings.TrimSpace(result.Stderr)) + } + lines := splitLines(result.Stdout) + return lines, nil +} + +func DiffNameStatusBetween(ctx context.Context, dir string, from string, to string, pathspecs []string) ([]FileChange, error) { + args := []string{"diff", "--name-status", "-M", fmt.Sprintf("%s..%s", from, to)} + if len(pathspecs) > 0 { + args = append(args, "--") + args = append(args, pathspecs...) + } + return runNameStatus(ctx, dir, args...) +} + +func DiffTreeNameStatus(ctx context.Context, dir string, ref string, pathspecs []string) ([]FileChange, error) { + args := []string{"diff-tree", "--no-commit-id", "--name-status", "-r", ref} + if len(pathspecs) > 0 { + args = append(args, "--") + args = append(args, pathspecs...) + } + return runNameStatus(ctx, dir, args...) +} + +func RevListRange(ctx context.Context, dir string, start string, end string) ([]string, error) { + result, err := Run(ctx, dir, nil, "rev-list", "--reverse", fmt.Sprintf("%s..%s", start, end)) + if err != nil { + return nil, err + } + if result.Code != 0 { + return nil, errors.New(strings.TrimSpace(result.Stderr)) + } + return splitLines(result.Stdout), nil +} + +func ApplyPatch(ctx context.Context, dir string, patch []byte) (string, error) { + strategies := [][]string{ + {"apply", "--ignore-whitespace", "--whitespace=nowarn", "-p1"}, + {"apply", "--ignore-whitespace", "--whitespace=nowarn", "-p1", "--3way"}, + {"apply", "--ignore-whitespace", "--whitespace=fix", "-p1"}, + {"apply", "--reject", "--ignore-whitespace", "--whitespace=nowarn", "-p1"}, + } + var lastErr string + for _, args := range strategies { + result, err := Run(ctx, dir, patch, args...) + if err != nil { + return "", err + } + if result.Code == 0 { + return strings.Join(args[1:], " "), nil + } + lastErr = strings.TrimSpace(result.Stderr) + } + if lastErr == "" { + lastErr = "git apply failed" + } + return "", errors.New(lastErr) +} + +func StashPush(ctx context.Context, dir string, message string, includeUntracked bool, paths []string) (string, error) { + args := []string{"stash", "push", "-m", message} + if includeUntracked { + args = append(args, "-u") + } + if len(paths) > 0 { + args = append(args, "--") + args = append(args, paths...) + } + result, err := Run(ctx, dir, nil, args...) + if err != nil { + return "", err + } + if result.Code != 0 { + return "", errors.New(strings.TrimSpace(result.Stderr)) + } + if strings.Contains(result.Stdout, "No local changes to save") { + return "", nil + } + list, err := Run(ctx, dir, nil, "stash", "list", "-1", "--format=%gd") + if err != nil { + return "", err + } + if list.Code != 0 { + return "", errors.New(strings.TrimSpace(list.Stderr)) + } + return strings.TrimSpace(list.Stdout), nil +} + +func StashPop(ctx context.Context, dir string, stashRef string) error { + args := []string{"stash", "pop"} + if stashRef != "" { + args = append(args, stashRef) + } + result, err := Run(ctx, dir, nil, args...) + if err != nil { + return err + } + if result.Code != 0 { + return errors.New(strings.TrimSpace(result.Stderr)) + } + return nil +} + +func PullRebase(ctx context.Context, dir string, remote string, branch string) error { + args := []string{"pull", "--rebase"} + if remote != "" { + args = append(args, remote) + if branch != "" { + args = append(args, branch) + } + } + result, err := Run(ctx, dir, nil, args...) + if err != nil { + return err + } + if result.Code != 0 { + return errors.New(strings.TrimSpace(result.Stderr)) + } + return nil +} + +func AddPaths(ctx context.Context, dir string, paths []string) error { + if len(paths) == 0 { + return nil + } + args := append([]string{"add", "--"}, paths...) + result, err := Run(ctx, dir, nil, args...) + if err != nil { + return err + } + if result.Code != 0 { + return errors.New(strings.TrimSpace(result.Stderr)) + } + return nil +} + +func Commit(ctx context.Context, dir string, message string) error { + result, err := Run(ctx, dir, nil, "commit", "-m", message) + if err != nil { + return err + } + if result.Code != 0 { + return errors.New(strings.TrimSpace(result.Stderr)) + } + return nil +} + +func Push(ctx context.Context, dir string, remote string, branch string) error { + args := []string{"push"} + if remote != "" { + args = append(args, remote) + } + if branch != "" { + args = append(args, branch) + } + result, err := Run(ctx, dir, nil, args...) + if err != nil { + return err + } + if result.Code != 0 { + return errors.New(strings.TrimSpace(result.Stderr)) + } + return nil +} + +func runNameStatus(ctx context.Context, dir string, args ...string) ([]FileChange, error) { + result, err := Run(ctx, dir, nil, args...) + if err != nil { + return nil, err + } + if result.Code != 0 { + return nil, errors.New(strings.TrimSpace(result.Stderr)) + } + var changes []FileChange + for _, line := range splitLines(result.Stdout) { + parts := strings.Split(line, "\t") + if len(parts) < 2 { + continue + } + change := FileChange{Status: parts[0][:1], Path: parts[len(parts)-1]} + if change.Status == "R" || change.Status == "C" { + if len(parts) >= 3 { + change.OldPath = parts[1] + } + } + changes = append(changes, change) + } + return changes, nil +} + +func splitLines(raw string) []string { + lines := strings.Split(strings.TrimSpace(raw), "\n") + if len(lines) == 1 && lines[0] == "" { + return nil + } + return lines +} diff --git a/packages/browseros/tools/bdev/internal/git/git_test.go b/packages/browseros/tools/bdev/internal/git/git_test.go new file mode 100644 index 000000000..07771e87c --- /dev/null +++ b/packages/browseros/tools/bdev/internal/git/git_test.go @@ -0,0 +1,28 @@ +package git + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestRunReturnsContextError(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + config := []byte("[alias]\n\thold = !sleep 5\n") + if err := os.WriteFile(filepath.Join(home, ".gitconfig"), config, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + if _, err := Run(ctx, t.TempDir(), nil, "hold"); err == nil { + t.Fatalf("expected timeout error") + } + if ctx.Err() != context.DeadlineExceeded { + t.Fatalf("expected context deadline exceeded, got %v", ctx.Err()) + } +} diff --git a/packages/browseros/tools/bdev/internal/git/log.go b/packages/browseros/tools/bdev/internal/git/log.go deleted file mode 100644 index 05a647a59..000000000 --- a/packages/browseros/tools/bdev/internal/git/log.go +++ /dev/null @@ -1,32 +0,0 @@ -package git - -import ( - "fmt" - "strings" -) - -// ChangedFilesSince returns files changed between two revs in a subdirectory. -func ChangedFilesSince(dir, fromRev, toRev, subdir string) ([]string, error) { - args := []string{"diff", "--name-only", fromRev + ".." + toRev} - if subdir != "" { - args = append(args, "--", subdir) - } - - out, err := Run(dir, args...) - if err != nil { - return nil, fmt.Errorf("diff --name-only %s..%s: %w", fromRev, toRev, err) - } - - if out == "" { - return nil, nil - } - - var files []string - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line != "" { - files = append(files, line) - } - } - return files, nil -} diff --git a/packages/browseros/tools/bdev/internal/git/remote.go b/packages/browseros/tools/bdev/internal/git/remote.go deleted file mode 100644 index f724697f9..000000000 --- a/packages/browseros/tools/bdev/internal/git/remote.go +++ /dev/null @@ -1,91 +0,0 @@ -package git - -import ( - "fmt" - "strings" -) - -func ListRemotes(dir string) ([]string, error) { - out, err := Run(dir, "remote") - if err != nil { - return nil, fmt.Errorf("listing remotes: %w", err) - } - if strings.TrimSpace(out) == "" { - return nil, nil - } - - var remotes []string - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line != "" { - remotes = append(remotes, line) - } - } - return remotes, nil -} - -func HasRemote(dir, remote string) (bool, error) { - remotes, err := ListRemotes(dir) - if err != nil { - return false, err - } - for _, name := range remotes { - if name == remote { - return true, nil - } - } - return false, nil -} - -func CurrentBranch(dir string) (branch string, detached bool, err error) { - out, runErr := Run(dir, "symbolic-ref", "--quiet", "--short", "HEAD") - if runErr != nil { - return "", true, nil - } - - branch = strings.TrimSpace(out) - if branch == "" { - return "", true, nil - } - return branch, false, nil -} - -func Fetch(dir, remote string) error { - _, err := Run(dir, "fetch", "--prune", remote) - if err != nil { - return fmt.Errorf("fetch %s: %w", remote, err) - } - return nil -} - -func Pull(dir, remote, branch string, rebase bool) error { - args := []string{"pull"} - if rebase { - args = append(args, "--rebase") - } else { - args = append(args, "--ff-only") - } - args = append(args, remote) - if strings.TrimSpace(branch) != "" { - args = append(args, branch) - } - - _, err := Run(dir, args...) - if err != nil { - return fmt.Errorf("pull %s/%s: %w", remote, branch, err) - } - return nil -} - -func Push(dir, remote, branch string) error { - args := []string{"push", remote} - if strings.TrimSpace(branch) != "" { - args = append(args, "HEAD:"+branch) - } - - _, err := Run(dir, args...) - if err != nil { - return fmt.Errorf("push %s/%s: %w", remote, branch, err) - } - return nil -} diff --git a/packages/browseros/tools/bdev/internal/git/remote_test.go b/packages/browseros/tools/bdev/internal/git/remote_test.go deleted file mode 100644 index 73902614b..000000000 --- a/packages/browseros/tools/bdev/internal/git/remote_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package git - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestRemoteSyncAndStatusHelpers(t *testing.T) { - t.Parallel() - - root := t.TempDir() - remote := filepath.Join(root, "remote.git") - repoA := filepath.Join(root, "repo-a") - repoB := filepath.Join(root, "repo-b") - - runGit(t, root, "init", "--bare", remote) - runGit(t, root, "clone", remote, repoA) - configRepo(t, repoA) - writeFile(t, filepath.Join(repoA, "README.md"), "one\n") - runGit(t, repoA, "add", "README.md") - runGit(t, repoA, "commit", "-m", "init") - - branch := strings.TrimSpace(runGit(t, repoA, "symbolic-ref", "--short", "HEAD")) - runGit(t, repoA, "push", "-u", "origin", branch) - runGit(t, root, "clone", remote, repoB) - configRepo(t, repoB) - - remotes, err := ListRemotes(repoB) - if err != nil { - t.Fatalf("ListRemotes: %v", err) - } - if len(remotes) != 1 || remotes[0] != "origin" { - t.Fatalf("unexpected remotes: %#v", remotes) - } - - writeFile(t, filepath.Join(repoA, "README.md"), "two\n") - runGit(t, repoA, "commit", "-am", "update") - runGit(t, repoA, "push", "origin", branch) - targetRev := strings.TrimSpace(runGit(t, repoA, "rev-parse", "HEAD")) - - if err := Fetch(repoB, "origin"); err != nil { - t.Fatalf("Fetch: %v", err) - } - curBranch, detached, err := CurrentBranch(repoB) - if err != nil { - t.Fatalf("CurrentBranch: %v", err) - } - if detached { - t.Fatalf("expected checked-out branch in clone") - } - if err := Pull(repoB, "origin", curBranch, true); err != nil { - t.Fatalf("Pull: %v", err) - } - - currentRev, err := HeadRev(repoB) - if err != nil { - t.Fatalf("HeadRev: %v", err) - } - if currentRev != targetRev { - t.Fatalf("repo-b did not fast-forward to latest rev: got %s want %s", currentRev, targetRev) - } - - writeFile(t, filepath.Join(repoB, "scratch.txt"), "dirty\n") - dirty, err := IsDirty(repoB) - if err != nil { - t.Fatalf("IsDirty: %v", err) - } - if !dirty { - t.Fatalf("expected repo to be dirty") - } -} - -func configRepo(t *testing.T, dir string) { - t.Helper() - runGit(t, dir, "config", "user.email", "bdev-test@example.com") - runGit(t, dir, "config", "user.name", "bdev test") -} - -func runGit(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, string(out)) - } - return string(out) -} - -func writeFile(t *testing.T, path, content string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatalf("write: %v", err) - } -} diff --git a/packages/browseros/tools/bdev/internal/git/rev.go b/packages/browseros/tools/bdev/internal/git/rev.go deleted file mode 100644 index 9bd2e3298..000000000 --- a/packages/browseros/tools/bdev/internal/git/rev.go +++ /dev/null @@ -1,27 +0,0 @@ -package git - -import "fmt" - -func RevParse(dir, ref string) (string, error) { - out, err := Run(dir, "rev-parse", ref) - if err != nil { - return "", fmt.Errorf("rev-parse %s: %w", ref, err) - } - return out, nil -} - -func CommitExists(dir, commit string) bool { - _, err := Run(dir, "cat-file", "-e", commit+"^{commit}") - return err == nil -} - -func HeadRev(dir string) (string, error) { - return RevParse(dir, "HEAD") -} - -// FileExistsInCommit checks whether a file path exists in a given commit. -// Uses git cat-file -e {commit}:{path}. -func FileExistsInCommit(dir, commit, filePath string) bool { - _, err := Run(dir, "cat-file", "-e", commit+":"+filePath) - return err == nil -} diff --git a/packages/browseros/tools/bdev/internal/git/status.go b/packages/browseros/tools/bdev/internal/git/status.go deleted file mode 100644 index dde2beca9..000000000 --- a/packages/browseros/tools/bdev/internal/git/status.go +++ /dev/null @@ -1,61 +0,0 @@ -package git - -import ( - "fmt" - "strings" -) - -func StatusPorcelain(dir string, pathspec ...string) ([]string, error) { - args := []string{"status", "--porcelain"} - if len(pathspec) > 0 { - args = append(args, "--") - args = append(args, pathspec...) - } - - out, err := Run(dir, args...) - if err != nil { - return nil, fmt.Errorf("status --porcelain: %w", err) - } - - if strings.TrimSpace(out) == "" { - return nil, nil - } - - var lines []string - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line != "" { - lines = append(lines, line) - } - } - return lines, nil -} - -func IsDirty(dir string, pathspec ...string) (bool, error) { - lines, err := StatusPorcelain(dir, pathspec...) - if err != nil { - return false, err - } - return len(lines) > 0, nil -} - -func Add(dir string, pathspec ...string) error { - args := []string{"add", "-A"} - if len(pathspec) > 0 { - args = append(args, "--") - args = append(args, pathspec...) - } - _, err := Run(dir, args...) - if err != nil { - return fmt.Errorf("add: %w", err) - } - return nil -} - -func Commit(dir, message string) error { - _, err := Run(dir, "commit", "-m", message) - if err != nil { - return fmt.Errorf("commit: %w", err) - } - return nil -} diff --git a/packages/browseros/tools/bdev/internal/log/activity.go b/packages/browseros/tools/bdev/internal/log/activity.go deleted file mode 100644 index 95fc359b6..000000000 --- a/packages/browseros/tools/bdev/internal/log/activity.go +++ /dev/null @@ -1,130 +0,0 @@ -package log - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "bdev/internal/patch" -) - -type Logger struct { - logFile string -} - -func New(brosDir string) *Logger { - return &Logger{ - logFile: filepath.Join(brosDir, "logs", "activity.log"), - } -} - -func (l *Logger) LogPush(base string, result *patch.PushResult) error { - var b strings.Builder - - b.WriteString(divider('=')) - b.WriteString(fmt.Sprintf("PUSH %s\n", time.Now().Format("2006-01-02 15:04:05"))) - b.WriteString(fmt.Sprintf("Base: %s\n", base)) - b.WriteString(divider('-')) - - for _, f := range result.Modified { - b.WriteString(fmt.Sprintf(" M %s\n", f)) - } - for _, f := range result.Added { - b.WriteString(fmt.Sprintf(" A %s\n", f)) - } - for _, f := range result.Deleted { - b.WriteString(fmt.Sprintf(" D %s\n", f)) - } - if len(result.Stale) > 0 { - b.WriteString("Stale removed:\n") - for _, f := range result.Stale { - b.WriteString(fmt.Sprintf(" %s\n", f)) - } - } - - b.WriteString(fmt.Sprintf("Summary: %d pushed (%d modified, %d added, %d deleted)", - result.Total(), len(result.Modified), len(result.Added), len(result.Deleted))) - if len(result.Stale) > 0 { - b.WriteString(fmt.Sprintf(", %d stale removed", len(result.Stale))) - } - b.WriteString("\n\n") - - return l.append(b.String()) -} - -func (l *Logger) LogPull(base, repoRev string, result *patch.PullResult) error { - var b strings.Builder - - b.WriteString(divider('=')) - b.WriteString(fmt.Sprintf("PULL %s\n", time.Now().Format("2006-01-02 15:04:05"))) - b.WriteString(fmt.Sprintf("Base: %s\n", base)) - b.WriteString(fmt.Sprintf("Patches repo rev: %s\n", repoRev)) - b.WriteString(divider('-')) - - for _, f := range result.Applied { - b.WriteString(fmt.Sprintf(" + %s\n", f)) - } - for _, f := range result.Deleted { - b.WriteString(fmt.Sprintf(" D %s\n", f)) - } - for _, f := range result.Reverted { - b.WriteString(fmt.Sprintf(" R %s (reverted to base)\n", f)) - } - for _, f := range result.LocalOnly { - b.WriteString(fmt.Sprintf(" ~ %s (local-only, kept)\n", f)) - } - for _, c := range result.Conflicts { - b.WriteString(fmt.Sprintf(" x %s -> %s (hunk %d/%d failed)\n", - c.File, c.RejectFile, c.HunksFailed, c.HunksTotal)) - } - if len(result.Skipped) > 0 { - b.WriteString(fmt.Sprintf(" ~ %d files skipped (already up to date)\n", len(result.Skipped))) - } - - b.WriteString(fmt.Sprintf("Summary: %d applied, %d deleted, %d reverted, %d local-only, %d conflicts, %d skipped\n\n", - len(result.Applied), len(result.Deleted), len(result.Reverted), len(result.LocalOnly), len(result.Conflicts), len(result.Skipped))) - - return l.append(b.String()) -} - -func (l *Logger) LogClone(base string, result *patch.PullResult) error { - var b strings.Builder - - b.WriteString(divider('=')) - b.WriteString(fmt.Sprintf("CLONE %s\n", time.Now().Format("2006-01-02 15:04:05"))) - b.WriteString(fmt.Sprintf("Base: %s\n", base)) - b.WriteString(divider('-')) - - for _, f := range result.Applied { - b.WriteString(fmt.Sprintf(" + %s\n", f)) - } - for _, c := range result.Conflicts { - b.WriteString(fmt.Sprintf(" x %s -> %s\n", c.File, c.RejectFile)) - } - - b.WriteString(fmt.Sprintf("Summary: %d applied, %d conflicts\n\n", - len(result.Applied), len(result.Conflicts))) - - return l.append(b.String()) -} - -func (l *Logger) append(entry string) error { - if err := os.MkdirAll(filepath.Dir(l.logFile), 0o755); err != nil { - return err - } - - f, err := os.OpenFile(l.logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer f.Close() - - _, err = f.WriteString(entry) - return err -} - -func divider(ch byte) string { - return strings.Repeat(string(ch), 50) + "\n" -} diff --git a/packages/browseros/tools/bdev/internal/patch/compare.go b/packages/browseros/tools/bdev/internal/patch/compare.go index a8b83a005..0f0471063 100644 --- a/packages/browseros/tools/bdev/internal/patch/compare.go +++ b/packages/browseros/tools/bdev/internal/patch/compare.go @@ -1,66 +1,65 @@ package patch import ( - "bytes" + "slices" "strings" ) -type Delta struct { - NeedsUpdate []string // In both, but content differs - NeedsApply []string // In repo only - UpToDate []string // In both, content matches - Orphaned []string // Local only, no repo patch - Deleted []string // .deleted markers in repo -} - -// Compare computes the delta between local patch set and repo patch set. -func Compare(local, repo *PatchSet) *Delta { - d := &Delta{} - - for path, repoPatch := range repo.Patches { - if repoPatch.Op == OpDeleted { - d.Deleted = append(d.Deleted, path) - continue - } - - localPatch, exists := local.Patches[path] - if !exists { - d.NeedsApply = append(d.NeedsApply, path) - continue - } - - if patchContentEqual(localPatch.Content, repoPatch.Content) { - d.UpToDate = append(d.UpToDate, path) - } else { - d.NeedsUpdate = append(d.NeedsUpdate, path) +func Compare(repo PatchSet, local PatchSet) []Delta { + seen := map[string]bool{} + var deltas []Delta + for rel, repoPatch := range repo { + seen[rel] = true + localPatch, ok := local[rel] + switch { + case !ok: + deltas = append(deltas, Delta{Path: rel, Kind: NeedsApply, Repo: ptr(repoPatch)}) + case signature(repoPatch) == signature(localPatch): + deltas = append(deltas, Delta{Path: rel, Kind: UpToDate, Repo: ptr(repoPatch), Local: ptr(localPatch)}) + default: + deltas = append(deltas, Delta{Path: rel, Kind: NeedsUpdate, Repo: ptr(repoPatch), Local: ptr(localPatch)}) } } - - for path := range local.Patches { - if _, exists := repo.Patches[path]; !exists { - d.Orphaned = append(d.Orphaned, path) - } - } - - return d -} - -func patchContentEqual(a, b []byte) bool { - return bytes.Equal( - normalizePatch(a), - normalizePatch(b), - ) -} - -func normalizePatch(content []byte) []byte { - lines := strings.Split(string(content), "\n") - var normalized []string - for _, line := range lines { - // Skip index lines (they contain hashes that may differ) - if strings.HasPrefix(line, "index ") { + for rel, localPatch := range local { + if seen[rel] { continue } - normalized = append(normalized, strings.TrimRight(line, " \t")) + deltas = append(deltas, Delta{Path: rel, Kind: Orphaned, Local: ptr(localPatch)}) } - return []byte(strings.Join(normalized, "\n")) + slices.SortFunc(deltas, func(a, b Delta) int { + if a.Path < b.Path { + return -1 + } + if a.Path > b.Path { + return 1 + } + return 0 + }) + return deltas +} + +func signature(p FilePatch) string { + switch { + case p.Op == OpDelete: + return "delete:" + NormalizeChromiumPath(p.Path) + case p.IsPureRename(): + return strings.Join([]string{"rename", NormalizeChromiumPath(p.OldPath), NormalizeChromiumPath(p.Path)}, ":") + case p.Op == OpBinary && len(p.Content) == 0: + return "binary:" + NormalizeChromiumPath(p.Path) + default: + lines := strings.Split(strings.ReplaceAll(string(p.Content), "\r\n", "\n"), "\n") + normalized := make([]string, 0, len(lines)) + for _, line := range lines { + if strings.HasPrefix(line, "index ") { + continue + } + normalized = append(normalized, strings.TrimRight(line, " \t")) + } + return strings.Join(append([]string{string(p.Op), NormalizeChromiumPath(p.Path), NormalizeChromiumPath(p.OldPath)}, normalized...), "\n") + } +} + +func ptr(p FilePatch) *FilePatch { + copy := p + return © } diff --git a/packages/browseros/tools/bdev/internal/patch/parser.go b/packages/browseros/tools/bdev/internal/patch/parser.go index 67357cbeb..da52d0df6 100644 --- a/packages/browseros/tools/bdev/internal/patch/parser.go +++ b/packages/browseros/tools/bdev/internal/patch/parser.go @@ -1,105 +1,79 @@ package patch import ( - "bytes" "fmt" + "regexp" + "strconv" "strings" ) -var diffHeaderPrefix = []byte("diff --git ") +var diffHeader = regexp.MustCompile(`^diff --git a/(.*) b/(.*)$`) -// ParseUnifiedDiff parses a full `git diff` output into individual file patches. -func ParseUnifiedDiff(raw []byte) (*PatchSet, error) { - ps := NewPatchSet("") +func ParseDiffOutput(diff string) (PatchSet, error) { + patches := PatchSet{} + lines := strings.SplitAfter(diff, "\n") + var current *FilePatch + var buffer strings.Builder - chunks := splitDiffChunks(raw) - for _, chunk := range chunks { - fp, err := parseSingleDiff(chunk) - if err != nil { - return nil, fmt.Errorf("parsing diff chunk: %w", err) + flush := func() error { + if current == nil { + return nil } - if fp != nil { - ps.Patches[fp.Path] = fp + current.Content = []byte(buffer.String()) + if current.Path == "" { + return fmt.Errorf("diff patch missing target path") } + patches[current.Path] = *current + current = nil + buffer.Reset() + return nil } - return ps, nil -} - -func splitDiffChunks(raw []byte) [][]byte { - var chunks [][]byte - lines := bytes.Split(raw, []byte("\n")) - - var current [][]byte for _, line := range lines { - if bytes.HasPrefix(line, diffHeaderPrefix) { - if len(current) > 0 { - chunks = append(chunks, bytes.Join(current, []byte("\n"))) + if strings.HasPrefix(line, "diff --git ") { + if err := flush(); err != nil { + return nil, err + } + match := diffHeader.FindStringSubmatch(strings.TrimRight(line, "\n")) + if len(match) != 3 { + return nil, fmt.Errorf("invalid diff header: %s", strings.TrimSpace(line)) + } + current = &FilePatch{ + Path: NormalizeChromiumPath(match[2]), + Op: OpModify, } - current = [][]byte{line} - } else if len(current) > 0 { - current = append(current, line) } - } - if len(current) > 0 { - chunks = append(chunks, bytes.Join(current, []byte("\n"))) - } - - return chunks -} - -func parseSingleDiff(chunk []byte) (*FilePatch, error) { - lines := strings.Split(string(chunk), "\n") - if len(lines) == 0 { - return nil, nil - } - - fp := &FilePatch{ - Op: OpModified, - Content: chunk, - } - - // Parse the diff --git a/... b/... header - header := lines[0] - if !strings.HasPrefix(header, "diff --git ") { - return nil, fmt.Errorf("unexpected header: %s", header) - } - - // Extract b/ path from the header - parts := strings.SplitN(header, " b/", 2) - if len(parts) == 2 { - fp.Path = parts[1] - } - - // Scan header lines for operation type - for _, line := range lines[1:] { - if strings.HasPrefix(line, "diff --git ") || strings.HasPrefix(line, "@@") { - break + if current == nil { + continue } - + buffer.WriteString(line) + trimmed := strings.TrimRight(line, "\n") switch { - case strings.HasPrefix(line, "new file mode"): - fp.Op = OpAdded - case strings.HasPrefix(line, "deleted file mode"): - fp.Op = OpDeleted - case strings.HasPrefix(line, "rename from "): - fp.Op = OpRenamed - fp.OldPath = strings.TrimPrefix(line, "rename from ") - case strings.HasPrefix(line, "rename to "): - fp.Path = strings.TrimPrefix(line, "rename to ") - case strings.HasPrefix(line, "similarity index "): - s := strings.TrimPrefix(line, "similarity index ") - s = strings.TrimSuffix(s, "%") - fmt.Sscanf(s, "%d", &fp.Similarity) - case strings.Contains(line, "Binary files"): - fp.Op = OpBinary - fp.IsBinary = true + case strings.HasPrefix(trimmed, "new file mode "): + current.Op = OpAdd + case strings.HasPrefix(trimmed, "deleted file mode "): + current.Op = OpDelete + case strings.HasPrefix(trimmed, "rename from "): + current.Op = OpRename + current.OldPath = NormalizeChromiumPath(strings.TrimPrefix(trimmed, "rename from ")) + case strings.HasPrefix(trimmed, "copy from "): + current.Op = OpCopy + current.OldPath = NormalizeChromiumPath(strings.TrimPrefix(trimmed, "copy from ")) + case strings.HasPrefix(trimmed, "similarity index "): + value := strings.TrimSuffix(strings.TrimPrefix(trimmed, "similarity index "), "%") + if similarity, err := strconv.Atoi(value); err == nil { + current.Similarity = similarity + } + case strings.HasPrefix(trimmed, "Binary files "): + current.IsBinary = true + current.Op = OpBinary + case trimmed == "GIT binary patch": + current.IsBinary = true } } - if fp.Path == "" { - return nil, nil + if err := flush(); err != nil { + return nil, err } - - return fp, nil + return patches, nil } diff --git a/packages/browseros/tools/bdev/internal/patch/patch_test.go b/packages/browseros/tools/bdev/internal/patch/patch_test.go new file mode 100644 index 000000000..3f102c807 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/patch/patch_test.go @@ -0,0 +1,146 @@ +package patch + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestParseDiffOutputDetectsRenameAndDeleteSignatures(t *testing.T) { + renameDiff := `diff --git a/chrome/old.cc b/chrome/new.cc +similarity index 100% +rename from chrome/old.cc +rename to chrome/new.cc +` + deleteDiff := `diff --git a/chrome/dead.cc b/chrome/dead.cc +deleted file mode 100644 +index 123..000 100644 +--- a/chrome/dead.cc ++++ /dev/null +@@ -1 +0,0 @@ +-gone +` + renameSet, err := ParseDiffOutput(renameDiff) + if err != nil { + t.Fatalf("ParseDiffOutput rename: %v", err) + } + deleteSet, err := ParseDiffOutput(deleteDiff) + if err != nil { + t.Fatalf("ParseDiffOutput delete: %v", err) + } + renamePatch := renameSet["chrome/new.cc"] + if !renamePatch.IsPureRename() { + t.Fatalf("expected pure rename patch") + } + if deletePatch := deleteSet["chrome/dead.cc"]; signature(deletePatch) != "delete:chrome/dead.cc" { + t.Fatalf("unexpected delete signature: %s", signature(deletePatch)) + } +} + +func TestWriteRepoPatchSetWritesMarkersAndReloads(t *testing.T) { + patchesDir := t.TempDir() + set := PatchSet{ + "chrome/dead.cc": { + Path: "chrome/dead.cc", + Op: OpDelete, + }, + "chrome/new.cc": { + Path: "chrome/new.cc", + Op: OpRename, + OldPath: "chrome/old.cc", + Similarity: 100, + Content: []byte(`diff --git a/chrome/old.cc b/chrome/new.cc +similarity index 100% +rename from chrome/old.cc +rename to chrome/new.cc +`), + }, + } + if _, _, err := WriteRepoPatchSet(patchesDir, set, nil); err != nil { + t.Fatalf("WriteRepoPatchSet: %v", err) + } + if _, err := filepath.Abs(filepath.Join(patchesDir, "chrome", "dead.cc.deleted")); err != nil { + t.Fatalf("abs: %v", err) + } + loaded, err := LoadRepoPatchSet(patchesDir, nil) + if err != nil { + t.Fatalf("LoadRepoPatchSet: %v", err) + } + if loaded["chrome/dead.cc"].Op != OpDelete { + t.Fatalf("expected delete marker to round-trip") + } + if !loaded["chrome/new.cc"].IsPureRename() { + t.Fatalf("expected rename marker to round-trip") + } +} + +func TestPathMatchesSkipsInternalState(t *testing.T) { + if PathMatches(".bdev/state.yaml", nil) { + t.Fatalf("expected internal state path to be ignored") + } +} + +func TestBuildRangePatchSetUsesLatestBaseScopedPatch(t *testing.T) { + ctx := context.Background() + repoDir := t.TempDir() + runGit(t, repoDir, "init") + runGit(t, repoDir, "config", "user.name", "Test User") + runGit(t, repoDir, "config", "user.email", "test@example.com") + + writeRepoFile(t, filepath.Join(repoDir, "chrome", "foo.txt"), "one\n") + runGit(t, repoDir, "add", "chrome/foo.txt") + runGit(t, repoDir, "commit", "-m", "base") + base := gitOutput(t, repoDir, "rev-parse", "HEAD") + + writeRepoFile(t, filepath.Join(repoDir, "chrome", "foo.txt"), "two\n") + runGit(t, repoDir, "commit", "-am", "step one") + writeRepoFile(t, filepath.Join(repoDir, "chrome", "foo.txt"), "three\n") + runGit(t, repoDir, "commit", "-am", "step two") + end := gitOutput(t, repoDir, "rev-parse", "HEAD") + + set, err := BuildRangePatchSet(ctx, repoDir, base, end, base, false, nil) + if err != nil { + t.Fatalf("BuildRangePatchSet: %v", err) + } + content := string(set["chrome/foo.txt"].Content) + if !strings.Contains(content, "+three") { + t.Fatalf("expected final patch content, got %q", content) + } + if strings.Contains(content, "+two") { + t.Fatalf("expected latest base-scoped patch, got %q", content) + } +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, string(output)) + } +} + +func gitOutput(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, string(output)) + } + return strings.TrimSpace(string(output)) +} + +func writeRepoFile(t *testing.T, path string, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write: %v", err) + } +} diff --git a/packages/browseros/tools/bdev/internal/patch/reader.go b/packages/browseros/tools/bdev/internal/patch/reader.go deleted file mode 100644 index 1f19b03d4..000000000 --- a/packages/browseros/tools/bdev/internal/patch/reader.go +++ /dev/null @@ -1,159 +0,0 @@ -package patch - -import ( - "context" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - - "golang.org/x/sync/errgroup" -) - -// ReadPatchSet reads all patches from the chromium_patches/ directory. -func ReadPatchSet(patchesDir string) (*PatchSet, error) { - ps := NewPatchSet("") - - // Collect file paths - var filePaths []string - err := filepath.Walk(patchesDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - if !info.IsDir() { - filePaths = append(filePaths, path) - } - return nil - }) - if err != nil { - return nil, err - } - - g, _ := errgroup.WithContext(context.Background()) - g.SetLimit(runtime.NumCPU()) - - var mu sync.Mutex - - for _, path := range filePaths { - path := path - g.Go(func() error { - content, err := os.ReadFile(path) - if err != nil { - return err - } - - rel, err := filepath.Rel(patchesDir, path) - if err != nil { - return err - } - - fp := classifyPatchFile(rel, content) - mu.Lock() - if existing, ok := ps.Patches[fp.Path]; ok { - ps.Patches[fp.Path] = mergePatchEntry(existing, fp) - } else { - ps.Patches[fp.Path] = fp - } - mu.Unlock() - return nil - }) - } - - if err := g.Wait(); err != nil { - return nil, err - } - - return ps, nil -} - -// ReadPatchFiles returns a map of chromium paths to true for all patches in the directory. -// Lighter than ReadPatchSet — only collects paths, not content. -func ReadPatchFiles(patchesDir string) (map[string]bool, error) { - result := make(map[string]bool) - - err := filepath.Walk(patchesDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - if info.IsDir() { - return nil - } - - rel, err := filepath.Rel(patchesDir, path) - if err != nil { - return nil - } - - chromPath := rel - chromPath = strings.TrimSuffix(chromPath, ".deleted") - chromPath = strings.TrimSuffix(chromPath, ".binary") - chromPath = strings.TrimSuffix(chromPath, ".rename") - - result[chromPath] = true - return nil - }) - - return result, err -} - -func classifyPatchFile(rel string, content []byte) *FilePatch { - fp := &FilePatch{ - Path: rel, - Content: content, - Op: OpModified, - } - - switch { - case strings.HasSuffix(rel, ".deleted"): - fp.Path = strings.TrimSuffix(rel, ".deleted") - fp.Op = OpDeleted - fp.Content = nil - case strings.HasSuffix(rel, ".binary"): - fp.Path = strings.TrimSuffix(rel, ".binary") - fp.Op = OpBinary - fp.IsBinary = true - fp.Content = nil - case strings.HasSuffix(rel, ".rename"): - fp.Path = strings.TrimSuffix(rel, ".rename") - fp.Op = OpRenamed - // Parse rename_from from content - for _, line := range strings.Split(string(content), "\n") { - if strings.HasPrefix(line, "rename_from: ") { - fp.OldPath = strings.TrimPrefix(line, "rename_from: ") - } - } - fp.Content = nil - default: - // Check if content looks like a diff with "new file mode" - if strings.Contains(string(content), "new file mode") { - fp.Op = OpAdded - } - } - - return fp -} - -func mergePatchEntry(existing, incoming *FilePatch) *FilePatch { - switch incoming.Op { - case OpDeleted, OpBinary: - return incoming - case OpRenamed: - merged := *incoming - if len(existing.Content) > 0 { - merged.Content = existing.Content - } - return &merged - } - - switch existing.Op { - case OpDeleted, OpBinary: - return existing - case OpRenamed: - merged := *existing - merged.Content = incoming.Content - return &merged - default: - return incoming - } -} diff --git a/packages/browseros/tools/bdev/internal/patch/reader_test.go b/packages/browseros/tools/bdev/internal/patch/reader_test.go deleted file mode 100644 index cec0a0f3d..000000000 --- a/packages/browseros/tools/bdev/internal/patch/reader_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package patch - -import ( - "os" - "path/filepath" - "testing" -) - -func TestReadPatchSetMarkerPrecedence(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - patchPath := filepath.Join(dir, "chrome", "browser", "foo.cc") - if err := os.MkdirAll(filepath.Dir(patchPath), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - - if err := os.WriteFile(patchPath, []byte("diff --git a/chrome/browser/foo.cc b/chrome/browser/foo.cc\n"), 0o644); err != nil { - t.Fatalf("write patch: %v", err) - } - if err := os.WriteFile(patchPath+".deleted", []byte("deleted: chrome/browser/foo.cc\n"), 0o644); err != nil { - t.Fatalf("write marker: %v", err) - } - - ps, err := ReadPatchSet(dir) - if err != nil { - t.Fatalf("ReadPatchSet: %v", err) - } - - fp, ok := ps.Patches["chrome/browser/foo.cc"] - if !ok { - t.Fatalf("missing patch entry") - } - if fp.Op != OpDeleted { - t.Fatalf("expected OpDeleted, got %v", fp.Op) - } - if len(fp.Content) != 0 { - t.Fatalf("expected empty content for deleted marker") - } -} - -func TestReadPatchSetRenameMergesContent(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - patchPath := filepath.Join(dir, "chrome", "browser", "new_name.cc") - if err := os.MkdirAll(filepath.Dir(patchPath), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - - diff := "diff --git a/chrome/browser/old_name.cc b/chrome/browser/new_name.cc\nrename from chrome/browser/old_name.cc\nrename to chrome/browser/new_name.cc\n" - if err := os.WriteFile(patchPath, []byte(diff), 0o644); err != nil { - t.Fatalf("write patch: %v", err) - } - if err := os.WriteFile(patchPath+".rename", []byte("rename_from: chrome/browser/old_name.cc\nsimilarity: 92\n"), 0o644); err != nil { - t.Fatalf("write rename marker: %v", err) - } - - ps, err := ReadPatchSet(dir) - if err != nil { - t.Fatalf("ReadPatchSet: %v", err) - } - - fp, ok := ps.Patches["chrome/browser/new_name.cc"] - if !ok { - t.Fatalf("missing rename patch entry") - } - if fp.Op != OpRenamed { - t.Fatalf("expected OpRenamed, got %v", fp.Op) - } - if fp.OldPath != "chrome/browser/old_name.cc" { - t.Fatalf("unexpected old path: %q", fp.OldPath) - } - if len(fp.Content) == 0 { - t.Fatalf("expected rename patch to keep diff content") - } -} diff --git a/packages/browseros/tools/bdev/internal/patch/repo.go b/packages/browseros/tools/bdev/internal/patch/repo.go new file mode 100644 index 000000000..702a5d624 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/patch/repo.go @@ -0,0 +1,164 @@ +package patch + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +func LoadRepoPatchSet(patchesDir string, filters []string) (PatchSet, error) { + set := PatchSet{} + err := filepath.WalkDir(patchesDir, func(fullPath string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + relPath, err := filepath.Rel(patchesDir, fullPath) + if err != nil { + return err + } + relPath = NormalizeChromiumPath(relPath) + patchFile, err := loadPatchFile(fullPath, relPath) + if err != nil { + return err + } + if !PathMatches(patchFile.Path, filters) { + return nil + } + set[patchFile.Path] = patchFile + return nil + }) + return set, err +} + +func WriteRepoPatchSet(patchesDir string, set PatchSet, scope []string) ([]string, []string, error) { + existing, err := LoadRepoPatchSet(patchesDir, nil) + if err != nil && !os.IsNotExist(err) { + return nil, nil, err + } + inScope := map[string]bool{} + if len(scope) == 0 { + for rel := range existing { + inScope[rel] = true + } + for rel := range set { + inScope[rel] = true + } + } else { + for _, rel := range scope { + inScope[NormalizeChromiumPath(rel)] = true + } + } + + var written []string + var deleted []string + for rel := range existing { + if !inScope[rel] || set[rel].Path != "" { + continue + } + if err := removePatchVariants(patchesDir, rel); err != nil { + return nil, nil, err + } + deleted = append(deleted, rel) + } + for rel, patchFile := range set { + if !inScope[rel] { + continue + } + if err := removePatchVariants(patchesDir, rel); err != nil { + return nil, nil, err + } + target, body := patchWriteTarget(patchesDir, patchFile) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return nil, nil, err + } + if len(body) == 0 || body[len(body)-1] != '\n' { + body = append(body, '\n') + } + if err := os.WriteFile(target, body, 0o644); err != nil { + return nil, nil, err + } + written = append(written, rel) + } + return written, deleted, nil +} + +func loadPatchFile(fullPath string, relPath string) (FilePatch, error) { + switch { + case strings.HasSuffix(relPath, ".deleted"): + return FilePatch{Path: strings.TrimSuffix(relPath, ".deleted"), Op: OpDelete}, nil + case strings.HasSuffix(relPath, ".binary"): + return FilePatch{Path: strings.TrimSuffix(relPath, ".binary"), Op: OpBinary, IsBinary: true}, nil + case strings.HasSuffix(relPath, ".rename"): + body, err := os.ReadFile(fullPath) + if err != nil { + return FilePatch{}, err + } + return parseRenameMarker(strings.TrimSuffix(relPath, ".rename"), string(body)) + default: + body, err := os.ReadFile(fullPath) + if err != nil { + return FilePatch{}, err + } + set, err := ParseDiffOutput(string(body)) + if err != nil { + return FilePatch{}, fmt.Errorf("parse %s: %w", relPath, err) + } + for _, patchFile := range set { + return patchFile, nil + } + return FilePatch{}, fmt.Errorf("empty patch file: %s", relPath) + } +} + +func patchWriteTarget(patchesDir string, patchFile FilePatch) (string, []byte) { + switch { + case patchFile.Op == OpDelete: + return filepath.Join(patchesDir, filepath.FromSlash(patchFile.Path+".deleted")), []byte("File deleted in patch") + case patchFile.IsPureRename(): + body := []byte(fmt.Sprintf("Renamed from: %s\nSimilarity: %d%%", patchFile.OldPath, patchFile.Similarity)) + return filepath.Join(patchesDir, filepath.FromSlash(patchFile.Path+".rename")), body + case patchFile.Op == OpBinary && len(patchFile.Content) == 0: + return filepath.Join(patchesDir, filepath.FromSlash(patchFile.Path+".binary")), []byte("Binary file") + default: + return filepath.Join(patchesDir, filepath.FromSlash(patchFile.Path)), patchFile.Content + } +} + +func removePatchVariants(patchesDir string, rel string) error { + variants := []string{ + filepath.Join(patchesDir, filepath.FromSlash(rel)), + filepath.Join(patchesDir, filepath.FromSlash(rel+".deleted")), + filepath.Join(patchesDir, filepath.FromSlash(rel+".binary")), + filepath.Join(patchesDir, filepath.FromSlash(rel+".rename")), + } + for _, variant := range variants { + if err := os.Remove(variant); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +func parseRenameMarker(rel string, body string) (FilePatch, error) { + patchFile := FilePatch{Path: rel, Op: OpRename} + for _, line := range strings.Split(body, "\n") { + switch { + case strings.HasPrefix(line, "Renamed from: "): + patchFile.OldPath = NormalizeChromiumPath(strings.TrimPrefix(line, "Renamed from: ")) + case strings.HasPrefix(line, "Similarity: "): + value := strings.TrimSuffix(strings.TrimPrefix(line, "Similarity: "), "%") + if similarity, err := strconv.Atoi(strings.TrimSpace(value)); err == nil { + patchFile.Similarity = similarity + } + } + } + if patchFile.OldPath == "" { + return FilePatch{}, fmt.Errorf("rename marker missing source path for %s", rel) + } + return patchFile, nil +} diff --git a/packages/browseros/tools/bdev/internal/patch/stale.go b/packages/browseros/tools/bdev/internal/patch/stale.go deleted file mode 100644 index 8d3fdefc2..000000000 --- a/packages/browseros/tools/bdev/internal/patch/stale.go +++ /dev/null @@ -1,75 +0,0 @@ -package patch - -import ( - "os" - "path/filepath" - "strings" -) - -// RemoveStale walks chromium_patches/ and removes patches for files -// that are NOT in the given PatchSet. -func RemoveStale(patchesDir string, current *PatchSet, dryRun bool) ([]string, error) { - var stale []string - - err := filepath.Walk(patchesDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - if info.IsDir() { - return nil - } - - rel, err := filepath.Rel(patchesDir, path) - if err != nil { - return nil - } - - // Normalize: strip marker suffixes to get the chromium path - chromPath := rel - chromPath = strings.TrimSuffix(chromPath, ".deleted") - chromPath = strings.TrimSuffix(chromPath, ".binary") - chromPath = strings.TrimSuffix(chromPath, ".rename") - - if _, exists := current.Patches[chromPath]; !exists { - stale = append(stale, rel) - if !dryRun { - _ = os.Remove(path) - } - } - - return nil - }) - - if err != nil { - return stale, err - } - - // Clean up empty directories - if !dryRun { - cleanEmptyDirs(patchesDir) - } - - return stale, nil -} - -func cleanEmptyDirs(root string) { - // Walk bottom-up by collecting dirs first then removing empty ones - var dirs []string - _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - if info.IsDir() && path != root { - dirs = append(dirs, path) - } - return nil - }) - - // Reverse order (deepest first) - for i := len(dirs) - 1; i >= 0; i-- { - entries, err := os.ReadDir(dirs[i]) - if err == nil && len(entries) == 0 { - _ = os.Remove(dirs[i]) - } - } -} diff --git a/packages/browseros/tools/bdev/internal/patch/types.go b/packages/browseros/tools/bdev/internal/patch/types.go index 0275cb481..bd47aac25 100644 --- a/packages/browseros/tools/bdev/internal/patch/types.go +++ b/packages/browseros/tools/bdev/internal/patch/types.go @@ -1,79 +1,80 @@ package patch -type FileOp int - -const ( - OpModified FileOp = iota - OpAdded - OpDeleted - OpRenamed - OpBinary +import ( + "bytes" + "path" + "path/filepath" + "strings" ) -func (op FileOp) String() string { - switch op { - case OpModified: - return "M" - case OpAdded: - return "A" - case OpDeleted: - return "D" - case OpRenamed: - return "R" - case OpBinary: - return "B" - default: - return "?" - } -} +type FileOp string + +const ( + OpAdd FileOp = "ADD" + OpModify FileOp = "MODIFY" + OpDelete FileOp = "DELETE" + OpRename FileOp = "RENAME" + OpCopy FileOp = "COPY" + OpBinary FileOp = "BINARY" +) type FilePatch struct { - Path string - Op FileOp - Content []byte - OldPath string // for renames - Similarity int // for renames - IsBinary bool + Path string `json:"path"` + Op FileOp `json:"op"` + OldPath string `json:"old_path,omitempty"` + Similarity int `json:"similarity,omitempty"` + Content []byte `json:"-"` + IsBinary bool `json:"is_binary,omitempty"` } -type PatchSet struct { - Base string - Patches map[string]*FilePatch // keyed by chromium path +type PatchSet map[string]FilePatch + +type DeltaKind string + +const ( + NeedsApply DeltaKind = "needs_apply" + NeedsUpdate DeltaKind = "needs_update" + UpToDate DeltaKind = "up_to_date" + Orphaned DeltaKind = "orphaned" +) + +type Delta struct { + Path string `json:"path"` + Kind DeltaKind `json:"kind"` + Repo *FilePatch `json:"repo,omitempty"` + Local *FilePatch `json:"local,omitempty"` } -func NewPatchSet(base string) *PatchSet { - return &PatchSet{ - Base: base, - Patches: make(map[string]*FilePatch), +func NormalizeChromiumPath(raw string) string { + clean := filepath.ToSlash(raw) + return strings.TrimPrefix(path.Clean(clean), "./") +} + +func PathMatches(rel string, filters []string) bool { + if IsInternalPath(rel) { + return false } + if len(filters) == 0 { + return true + } + candidate := NormalizeChromiumPath(rel) + for _, filter := range filters { + scope := NormalizeChromiumPath(filter) + if candidate == scope || strings.HasPrefix(candidate, scope+"/") { + return true + } + } + return false } -type PushResult struct { - Modified []string - Added []string - Deleted []string - Stale []string - Unchanged []string +func IsInternalPath(rel string) bool { + candidate := NormalizeChromiumPath(rel) + return candidate == ".bdev" || strings.HasPrefix(candidate, ".bdev/") } -func (r *PushResult) Total() int { - return len(r.Modified) + len(r.Added) + len(r.Deleted) -} - -type PullResult struct { - Applied []string - Skipped []string - Reverted []string - LocalOnly []string - Conflicts []ConflictInfo - Deleted []string -} - -type ConflictInfo struct { - File string - RejectFile string - PatchFile string - HunksTotal int - HunksFailed int - Error string +func (p FilePatch) IsPureRename() bool { + if p.Op != OpRename { + return false + } + return !bytes.Contains(p.Content, []byte("\n@@")) && !bytes.Contains(p.Content, []byte("GIT binary patch")) } diff --git a/packages/browseros/tools/bdev/internal/patch/workspace.go b/packages/browseros/tools/bdev/internal/patch/workspace.go new file mode 100644 index 000000000..1a00a2690 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/patch/workspace.go @@ -0,0 +1,215 @@ +package patch + +import ( + "context" + "fmt" + "path/filepath" + "slices" + "strings" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/git" +) + +func BuildWorkingTreePatchSet(ctx context.Context, workspacePath string, base string, filters []string) (PatchSet, error) { + diff, err := git.DiffText(ctx, workspacePath, base) + if err != nil { + return nil, err + } + set, err := ParseDiffOutput(diff) + if err != nil { + return nil, err + } + untracked, err := git.ListUntracked(ctx, workspacePath, filters) + if err != nil { + return nil, err + } + for _, rel := range untracked { + diffText, err := git.DiffNoIndex(ctx, workspacePath, rel) + if err != nil { + return nil, err + } + untrackedSet, err := ParseDiffOutput(diffText) + if err != nil { + return nil, err + } + for patchPath, patchFile := range untrackedSet { + set[patchPath] = patchFile + } + } + return filterSet(set, filters), nil +} + +func BuildCommitPatchSet(ctx context.Context, workspacePath string, ref string, base string, filters []string) (PatchSet, error) { + if base == "" { + diff, err := git.DiffText(ctx, workspacePath, ref+"^.."+ref) + if err != nil { + return nil, err + } + set, err := ParseDiffOutput(diff) + if err != nil { + return nil, err + } + return filterSet(set, filters), nil + } + changes, err := git.DiffTreeNameStatus(ctx, workspacePath, ref, filters) + if err != nil { + return nil, err + } + return buildBaseScopedSet(ctx, workspacePath, ref, base, changes) +} + +func BuildRangePatchSet(ctx context.Context, workspacePath string, start string, end string, base string, squash bool, filters []string) (PatchSet, error) { + if squash { + if base == "" { + diff, err := git.DiffText(ctx, workspacePath, start+".."+end) + if err != nil { + return nil, err + } + set, err := ParseDiffOutput(diff) + if err != nil { + return nil, err + } + return filterSet(set, filters), nil + } + changes, err := git.DiffNameStatusBetween(ctx, workspacePath, start, end, filters) + if err != nil { + return nil, err + } + return buildBaseScopedSet(ctx, workspacePath, end, base, changes) + } + + commits, err := git.RevListRange(ctx, workspacePath, start, end) + if err != nil { + return nil, err + } + set := PatchSet{} + seen := map[string]bool{} + for _, commit := range commits { + var current PatchSet + if base == "" { + diff, err := git.DiffText(ctx, workspacePath, commit+"^.."+commit) + if err != nil { + return nil, err + } + current, err = ParseDiffOutput(diff) + if err != nil { + return nil, err + } + } else { + changes, err := git.DiffTreeNameStatus(ctx, workspacePath, commit, filters) + if err != nil { + return nil, err + } + current, err = buildBaseScopedSet(ctx, workspacePath, commit, base, changes) + if err != nil { + return nil, err + } + } + for rel, patchFile := range filterSet(current, filters) { + if base != "" { + set[rel] = patchFile + continue + } + if seen[rel] { + continue + } + set[rel] = patchFile + seen[rel] = true + } + } + return set, nil +} + +func buildBaseScopedSet(ctx context.Context, workspacePath string, ref string, base string, changes []git.FileChange) (PatchSet, error) { + set := PatchSet{} + for _, change := range changes { + rel := NormalizeChromiumPath(change.Path) + diff, err := git.DiffText(ctx, workspacePath, base, ref, "--", rel) + if err != nil { + return nil, err + } + switch { + case strings.TrimSpace(diff) != "": + patches, err := ParseDiffOutput(diff) + if err != nil { + return nil, err + } + for patchPath, patchFile := range patches { + set[patchPath] = patchFile + } + case change.Status == "D": + exists, err := git.FileExistsAtCommit(ctx, workspacePath, base, rel) + if err != nil { + return nil, err + } + if exists { + set[rel] = FilePatch{Path: rel, Op: OpDelete} + } + case change.Status == "A": + content, err := git.ShowFile(ctx, workspacePath, ref, rel) + if err != nil { + return nil, err + } + set[rel] = syntheticAddPatch(rel, content) + } + } + return set, nil +} + +func filterSet(set PatchSet, filters []string) PatchSet { + filtered := PatchSet{} + for rel, patchFile := range set { + if !PathMatches(rel, filters) { + continue + } + filtered[rel] = patchFile + } + return filtered +} + +func ScopeFromSet(set PatchSet) []string { + paths := make([]string, 0, len(set)) + for rel := range set { + paths = append(paths, rel) + } + slices.Sort(paths) + return paths +} + +func RejectPath(workspacePath string, rel string) string { + return filepath.Join(workspacePath, filepath.FromSlash(rel+".rej")) +} + +func syntheticAddPatch(rel string, content []byte) FilePatch { + body := string(content) + if body != "" && body[len(body)-1] != '\n' { + body += "\n" + } + patchBody := fmt.Sprintf( + "diff --git a/%s b/%s\nnew file mode 100644\n--- /dev/null\n+++ b/%s\n@@ -0,0 +1,%d @@\n%s", + rel, + rel, + rel, + countLines(body), + prefixLines(body, "+"), + ) + return FilePatch{Path: rel, Op: OpAdd, Content: []byte(patchBody)} +} + +func countLines(body string) int { + if body == "" { + return 0 + } + return len(strings.Split(strings.TrimSuffix(body, "\n"), "\n")) +} + +func prefixLines(body string, prefix string) string { + lines := strings.Split(strings.TrimSuffix(body, "\n"), "\n") + for idx, line := range lines { + lines[idx] = prefix + line + } + if len(lines) == 0 { + return "" + } + return strings.Join(lines, "\n") + "\n" +} diff --git a/packages/browseros/tools/bdev/internal/patch/writer.go b/packages/browseros/tools/bdev/internal/patch/writer.go deleted file mode 100644 index b09a1763b..000000000 --- a/packages/browseros/tools/bdev/internal/patch/writer.go +++ /dev/null @@ -1,119 +0,0 @@ -package patch - -import ( - "context" - "fmt" - "os" - "path/filepath" - "runtime" - - "golang.org/x/sync/errgroup" -) - -// WritePatchSet writes patches to the chromium_patches/ directory. -func WritePatchSet(patchesDir string, ps *PatchSet, dryRun bool) error { - g, _ := errgroup.WithContext(context.Background()) - g.SetLimit(runtime.NumCPU()) - - for _, fp := range ps.Patches { - fp := fp - g.Go(func() error { - return writeSinglePatch(patchesDir, fp, dryRun) - }) - } - - return g.Wait() -} - -func writeSinglePatch(patchesDir string, fp *FilePatch, dryRun bool) error { - if dryRun { - return nil - } - - switch fp.Op { - case OpDeleted: - if err := cleanupArtifacts(patchesDir, fp.Path, fp.Path+".deleted"); err != nil { - return err - } - return writeDeletedMarker(patchesDir, fp) - case OpBinary: - if err := cleanupArtifacts(patchesDir, fp.Path, fp.Path+".binary"); err != nil { - return err - } - return writeBinaryMarker(patchesDir, fp) - case OpRenamed: - if err := cleanupArtifacts(patchesDir, fp.Path, fp.Path, fp.Path+".rename"); err != nil { - return err - } - if err := writePatchFile(patchesDir, fp); err != nil { - return err - } - return writeRenameMarker(patchesDir, fp) - default: - if err := cleanupArtifacts(patchesDir, fp.Path, fp.Path); err != nil { - return err - } - return writePatchFile(patchesDir, fp) - } -} - -func writePatchFile(patchesDir string, fp *FilePatch) error { - dest := filepath.Join(patchesDir, fp.Path) - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return fmt.Errorf("creating directory: %w", err) - } - return os.WriteFile(dest, fp.Content, 0o644) -} - -func writeDeletedMarker(patchesDir string, fp *FilePatch) error { - dest := filepath.Join(patchesDir, fp.Path+".deleted") - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return fmt.Errorf("creating directory: %w", err) - } - content := fmt.Sprintf("deleted: %s\n", fp.Path) - return os.WriteFile(dest, []byte(content), 0o644) -} - -func writeBinaryMarker(patchesDir string, fp *FilePatch) error { - dest := filepath.Join(patchesDir, fp.Path+".binary") - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return fmt.Errorf("creating directory: %w", err) - } - content := fmt.Sprintf("binary: %s\n", fp.Path) - return os.WriteFile(dest, []byte(content), 0o644) -} - -func writeRenameMarker(patchesDir string, fp *FilePatch) error { - dest := filepath.Join(patchesDir, fp.Path+".rename") - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return fmt.Errorf("creating directory: %w", err) - } - content := fmt.Sprintf("rename_from: %s\nsimilarity: %d\n", fp.OldPath, fp.Similarity) - return os.WriteFile(dest, []byte(content), 0o644) -} - -func cleanupArtifacts(patchesDir, path string, keep ...string) error { - keepSet := make(map[string]bool, len(keep)) - for _, k := range keep { - keepSet[k] = true - } - - candidates := []string{ - path, - path + ".deleted", - path + ".binary", - path + ".rename", - } - - for _, rel := range candidates { - if keepSet[rel] { - continue - } - full := filepath.Join(patchesDir, rel) - if err := os.Remove(full); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("removing stale artifact %s: %w", rel, err) - } - } - - return nil -} diff --git a/packages/browseros/tools/bdev/internal/patch/writer_test.go b/packages/browseros/tools/bdev/internal/patch/writer_test.go deleted file mode 100644 index d8fce8537..000000000 --- a/packages/browseros/tools/bdev/internal/patch/writer_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package patch - -import ( - "os" - "path/filepath" - "testing" -) - -func TestWritePatchSetDeletedCleansSiblingArtifacts(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - chromPath := "components/foo/bar.cc" - basePath := filepath.Join(dir, chromPath) - - if err := os.MkdirAll(filepath.Dir(basePath), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - - preExisting := []string{ - basePath, - basePath + ".binary", - basePath + ".rename", - } - for _, p := range preExisting { - if err := os.WriteFile(p, []byte("old"), 0o644); err != nil { - t.Fatalf("write %s: %v", p, err) - } - } - - ps := NewPatchSet("") - ps.Patches[chromPath] = &FilePatch{Path: chromPath, Op: OpDeleted} - if err := WritePatchSet(dir, ps, false); err != nil { - t.Fatalf("WritePatchSet: %v", err) - } - - if _, err := os.Stat(basePath + ".deleted"); err != nil { - t.Fatalf("expected deleted marker: %v", err) - } - for _, stale := range preExisting { - if _, err := os.Stat(stale); !os.IsNotExist(err) { - t.Fatalf("expected stale artifact removed: %s", stale) - } - } -} - -func TestWritePatchSetRenameKeepsPatchAndRenameMarker(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - chromPath := "content/new_file.cc" - basePath := filepath.Join(dir, chromPath) - if err := os.MkdirAll(filepath.Dir(basePath), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - - if err := os.WriteFile(basePath+".deleted", []byte("stale"), 0o644); err != nil { - t.Fatalf("seed marker: %v", err) - } - if err := os.WriteFile(basePath+".binary", []byte("stale"), 0o644); err != nil { - t.Fatalf("seed marker: %v", err) - } - - ps := NewPatchSet("") - ps.Patches[chromPath] = &FilePatch{ - Path: chromPath, - Op: OpRenamed, - OldPath: "content/old_file.cc", - Similarity: 95, - Content: []byte("diff --git a/content/old_file.cc b/content/new_file.cc\n"), - } - if err := WritePatchSet(dir, ps, false); err != nil { - t.Fatalf("WritePatchSet: %v", err) - } - - if _, err := os.Stat(basePath); err != nil { - t.Fatalf("expected rename patch content file: %v", err) - } - if _, err := os.Stat(basePath + ".rename"); err != nil { - t.Fatalf("expected rename marker file: %v", err) - } - for _, stale := range []string{basePath + ".deleted", basePath + ".binary"} { - if _, err := os.Stat(stale); !os.IsNotExist(err) { - t.Fatalf("expected stale artifact removed: %s", stale) - } - } -} diff --git a/packages/browseros/tools/bdev/internal/repo/repo.go b/packages/browseros/tools/bdev/internal/repo/repo.go new file mode 100644 index 000000000..85d5d1d99 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/repo/repo.go @@ -0,0 +1,61 @@ +package repo + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type Info struct { + Root string `json:"root"` + PatchesDir string `json:"patches_dir"` + BaseCommit string `json:"base_commit"` +} + +func Discover(start string) (string, error) { + abs, err := filepath.Abs(start) + if err != nil { + return "", err + } + current := filepath.Clean(abs) + for { + if hasRepoMarkers(current) { + return current, nil + } + next := filepath.Dir(current) + if next == current { + break + } + current = next + } + return "", fmt.Errorf("unable to find patches repo from %s", start) +} + +func Load(root string) (*Info, error) { + abs, err := filepath.Abs(root) + if err != nil { + return nil, err + } + clean := filepath.Clean(abs) + if !hasRepoMarkers(clean) { + return nil, fmt.Errorf("not a browseros patches repo: %s", clean) + } + base, err := os.ReadFile(filepath.Join(clean, "BASE_COMMIT")) + if err != nil { + return nil, err + } + return &Info{ + Root: clean, + PatchesDir: filepath.Join(clean, "chromium_patches"), + BaseCommit: strings.TrimSpace(string(base)), + }, nil +} + +func hasRepoMarkers(root string) bool { + if _, err := os.Stat(filepath.Join(root, "BASE_COMMIT")); err != nil { + return false + } + info, err := os.Stat(filepath.Join(root, "chromium_patches")) + return err == nil && info.IsDir() +} diff --git a/packages/browseros/tools/bdev/internal/resolve/resolve.go b/packages/browseros/tools/bdev/internal/resolve/resolve.go new file mode 100644 index 000000000..497d01a86 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/resolve/resolve.go @@ -0,0 +1,98 @@ +package resolve + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/patch" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace" +) + +type Operation struct { + ChromiumPath string `json:"chromium_path"` + PatchRel string `json:"patch_rel"` + Op patch.FileOp `json:"op"` + OldPath string `json:"old_path,omitempty"` + RejectPath string `json:"reject_path,omitempty"` + Message string `json:"message,omitempty"` +} + +type State struct { + Workspace string `json:"workspace"` + RepoRoot string `json:"repo_root"` + BaseCommit string `json:"base_commit"` + RepoRev string `json:"repo_rev,omitempty"` + Mode string `json:"mode,omitempty"` + Current int `json:"current"` + Operations []Operation `json:"operations"` + Resolved []string `json:"resolved,omitempty"` + Skipped []string `json:"skipped,omitempty"` +} + +func Path(workspacePath string) string { + return filepath.Join(workspace.StateDir(workspacePath), "resolve.json") +} + +func Exists(workspacePath string) bool { + _, err := os.Stat(Path(workspacePath)) + return err == nil +} + +func Load(workspacePath string) (*State, error) { + data, err := os.ReadFile(Path(workspacePath)) + if err != nil { + return nil, err + } + var state State + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + return &state, nil +} + +func Save(workspacePath string, state *State) error { + if err := os.MkdirAll(workspace.StateDir(workspacePath), 0o755); err != nil { + return err + } + body, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return os.WriteFile(Path(workspacePath), append(body, '\n'), 0o644) +} + +func Delete(workspacePath string) error { + if err := os.Remove(Path(workspacePath)); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func FindActive(reg *workspace.Registry, cwd string) (workspace.Entry, error) { + if ws, err := workspace.Detect(reg, cwd); err == nil && Exists(ws.Path) { + return ws, nil + } + var active []workspace.Entry + for _, ws := range reg.Workspaces { + if Exists(ws.Path) { + active = append(active, ws) + } + } + switch len(active) { + case 0: + return workspace.Entry{}, fmt.Errorf(`no active conflict resolution found; run "bdev apply" or "bdev sync --rebase" first`) + case 1: + return active[0], nil + default: + return workspace.Entry{}, fmt.Errorf("multiple workspaces have active conflicts; run from inside the target workspace") + } +} + +func (s *State) CurrentOperation() (Operation, error) { + if s.Current < 0 || s.Current >= len(s.Operations) { + return Operation{}, fmt.Errorf("no active conflict remaining") + } + return s.Operations[s.Current], nil +} diff --git a/packages/browseros/tools/bdev/internal/resolve/resolve_test.go b/packages/browseros/tools/bdev/internal/resolve/resolve_test.go new file mode 100644 index 000000000..4cbbe7276 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/resolve/resolve_test.go @@ -0,0 +1,59 @@ +package resolve + +import ( + "testing" + + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/patch" + "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/internal/workspace" +) + +func TestStateRoundTrip(t *testing.T) { + workspacePath := t.TempDir() + state := &State{ + Workspace: workspacePath, + RepoRoot: "/tmp/repo", + BaseCommit: "abc123", + Current: 1, + Operations: []Operation{{ + ChromiumPath: "chrome/browser/foo.cc", + PatchRel: "chrome/browser/foo.cc", + Op: patch.OpModify, + }}, + } + if err := Save(workspacePath, state); err != nil { + t.Fatalf("Save: %v", err) + } + loaded, err := Load(workspacePath) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.BaseCommit != state.BaseCommit || loaded.Current != state.Current { + t.Fatalf("state mismatch: %#v", loaded) + } + if err := Delete(workspacePath); err != nil { + t.Fatalf("Delete: %v", err) + } + if Exists(workspacePath) { + t.Fatalf("expected resolve state to be deleted") + } +} + +func TestFindActivePrefersCurrentWorkspace(t *testing.T) { + root := t.TempDir() + workspaceA := workspace.Entry{Name: "a", Path: root + "/a"} + workspaceB := workspace.Entry{Name: "b", Path: root + "/b"} + for _, ws := range []workspace.Entry{workspaceA, workspaceB} { + if err := Save(ws.Path, &State{Workspace: ws.Path}); err != nil { + t.Fatalf("save %s: %v", ws.Name, err) + } + } + + reg := &workspace.Registry{Workspaces: []workspace.Entry{workspaceA, workspaceB}} + active, err := FindActive(reg, workspaceB.Path) + if err != nil { + t.Fatalf("FindActive: %v", err) + } + if active.Name != workspaceB.Name { + t.Fatalf("expected %q, got %q", workspaceB.Name, active.Name) + } +} diff --git a/packages/browseros/tools/bdev/internal/ui/activity.go b/packages/browseros/tools/bdev/internal/ui/activity.go deleted file mode 100644 index ca7b98602..000000000 --- a/packages/browseros/tools/bdev/internal/ui/activity.go +++ /dev/null @@ -1,58 +0,0 @@ -package ui - -import ( - "fmt" - "strings" -) - -var ( - StepPrefix = TitleStyle.Render(">") - InfoPrefix = SubtitleStyle.Render("i") - OkPrefix = SuccessStyle.Render("✓") - WarnPrefix = WarningStyle.Render("!") - ErrorPrefix = ErrorStyle.Render("x") -) - -type Activity struct { - Verbose bool -} - -func NewActivity(verbose bool) *Activity { - return &Activity{Verbose: verbose} -} - -func (a *Activity) Header(title string) { - fmt.Println(TitleStyle.Render(title)) - fmt.Println() -} - -func (a *Activity) Step(format string, args ...any) { - fmt.Printf(" %s %s\n", StepPrefix, fmt.Sprintf(format, args...)) -} - -func (a *Activity) Info(format string, args ...any) { - fmt.Printf(" %s %s\n", InfoPrefix, fmt.Sprintf(format, args...)) -} - -func (a *Activity) Detail(format string, args ...any) { - if !a.Verbose { - return - } - fmt.Printf(" %s %s\n", InfoPrefix, fmt.Sprintf(format, args...)) -} - -func (a *Activity) Success(format string, args ...any) { - fmt.Printf(" %s %s\n", OkPrefix, fmt.Sprintf(format, args...)) -} - -func (a *Activity) Warn(format string, args ...any) { - fmt.Printf(" %s %s\n", WarnPrefix, fmt.Sprintf(format, args...)) -} - -func (a *Activity) Error(format string, args ...any) { - fmt.Printf(" %s %s\n", ErrorPrefix, fmt.Sprintf(format, args...)) -} - -func (a *Activity) Divider() { - fmt.Println(MutedStyle.Render(strings.Repeat("-", 58))) -} diff --git a/packages/browseros/tools/bdev/internal/ui/output.go b/packages/browseros/tools/bdev/internal/ui/output.go deleted file mode 100644 index bd2e94c0c..000000000 --- a/packages/browseros/tools/bdev/internal/ui/output.go +++ /dev/null @@ -1,46 +0,0 @@ -package ui - -import ( - "fmt" - "strings" - - "bdev/internal/patch" -) - -func RenderPullResult(r *patch.PullResult) string { - var b strings.Builder - - b.WriteString(TitleStyle.Render("bdev pull")) - b.WriteString("\n\n") - - for _, f := range r.Applied { - b.WriteString(fmt.Sprintf(" %s %s\n", SuccessStyle.Render("+"), f)) - } - for _, c := range r.Conflicts { - b.WriteString(fmt.Sprintf(" %s %s\n", ErrorStyle.Render("x"), c.File)) - } - for _, f := range r.Deleted { - b.WriteString(fmt.Sprintf(" %s %s\n", DeletedPrefix, f)) - } - for _, f := range r.Reverted { - b.WriteString(fmt.Sprintf(" %s %s %s\n", ModifiedPrefix, f, MutedStyle.Render("(reverted to base)"))) - } - for _, f := range r.LocalOnly { - b.WriteString(fmt.Sprintf(" %s %s %s\n", SkippedPrefix, f, MutedStyle.Render("(local-only, kept)"))) - } - if len(r.Skipped) > 0 { - b.WriteString(fmt.Sprintf(" %s %s\n", SkippedPrefix, - MutedStyle.Render(fmt.Sprintf("%d files skipped (already up to date)", len(r.Skipped))))) - } - - b.WriteString("\n") - - total := len(r.Applied) + len(r.Deleted) + len(r.Reverted) + len(r.LocalOnly) + len(r.Conflicts) + len(r.Skipped) - summary := fmt.Sprintf("Pulled %d patch paths", total) - b.WriteString(SuccessStyle.Render(summary)) - b.WriteString(MutedStyle.Render(fmt.Sprintf(" (%d applied, %d deleted, %d reverted, %d local-only, %d conflicts, %d skipped)", - len(r.Applied), len(r.Deleted), len(r.Reverted), len(r.LocalOnly), len(r.Conflicts), len(r.Skipped)))) - b.WriteString("\n") - - return b.String() -} diff --git a/packages/browseros/tools/bdev/internal/ui/report.go b/packages/browseros/tools/bdev/internal/ui/report.go deleted file mode 100644 index d27120e69..000000000 --- a/packages/browseros/tools/bdev/internal/ui/report.go +++ /dev/null @@ -1,41 +0,0 @@ -package ui - -import ( - "encoding/json" - "fmt" - "strings" - - "bdev/internal/patch" -) - -func RenderConflictReport(conflicts []patch.ConflictInfo) string { - if len(conflicts) == 0 { - return "" - } - - var b strings.Builder - - b.WriteString("\n") - b.WriteString(ErrorStyle.Render("=== CONFLICT REPORT ===")) - b.WriteString("\n\n") - - for i, c := range conflicts { - b.WriteString(fmt.Sprintf("file: %s\n", c.File)) - b.WriteString(fmt.Sprintf("reject_file: %s\n", c.RejectFile)) - b.WriteString(fmt.Sprintf("patch_file: %s\n", c.PatchFile)) - if c.HunksTotal > 0 { - b.WriteString(fmt.Sprintf("hunks_total: %d\n", c.HunksTotal)) - b.WriteString(fmt.Sprintf("hunks_failed: %d\n", c.HunksFailed)) - } - if i < len(conflicts)-1 { - b.WriteString("---\n") - } - } - - return ConflictBox.Render(b.String()) -} - -func RenderConflictReportJSON(conflicts []patch.ConflictInfo) string { - data, _ := json.MarshalIndent(conflicts, "", " ") - return string(data) -} diff --git a/packages/browseros/tools/bdev/internal/ui/styles.go b/packages/browseros/tools/bdev/internal/ui/styles.go deleted file mode 100644 index 526c5dda8..000000000 --- a/packages/browseros/tools/bdev/internal/ui/styles.go +++ /dev/null @@ -1,31 +0,0 @@ -package ui - -import "github.com/charmbracelet/lipgloss" - -var ( - TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) - SubtitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - - SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) - WarningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) - ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) - MutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - - ModifiedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Render("M") - AddedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("A") - DeletedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render("D") - SkippedPrefix = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("~") - - ConflictBox = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("9")). - Padding(0, 1) - - StatusBox = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("12")). - Padding(0, 1) - - LabelStyle = lipgloss.NewStyle().Width(16).Foreground(lipgloss.Color("8")) - ValueStyle = lipgloss.NewStyle().Bold(true) -) diff --git a/packages/browseros/tools/bdev/internal/ui/ui.go b/packages/browseros/tools/bdev/internal/ui/ui.go new file mode 100644 index 000000000..c625ae188 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/ui/ui.go @@ -0,0 +1,84 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +var ( + clrCyan = lipgloss.Color("6") + clrBlue = lipgloss.Color("12") + clrGreen = lipgloss.Color("2") + clrHiGreen = lipgloss.Color("10") + clrYellow = lipgloss.Color("11") + clrRed = lipgloss.Color("9") + clrGray = lipgloss.Color("8") +) + +var ( + TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(clrCyan) + HeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(clrCyan) + CommandStyle = lipgloss.NewStyle().Foreground(clrHiGreen) + AliasStyle = lipgloss.NewStyle().Foreground(clrYellow) + HintStyle = lipgloss.NewStyle().Faint(true) + SuccessStyle = lipgloss.NewStyle().Foreground(clrGreen).Bold(true) + WarningStyle = lipgloss.NewStyle().Foreground(clrYellow).Bold(true) + ErrorStyle = lipgloss.NewStyle().Foreground(clrRed).Bold(true) + InfoStyle = lipgloss.NewStyle().Foreground(clrBlue) + MutedStyle = lipgloss.NewStyle().Foreground(clrGray) +) + +func Title(s string) string { + return TitleStyle.Render(s) +} + +func Header(s string) string { + return HeaderStyle.Render(s) +} + +func Command(s string) string { + return CommandStyle.Render(s) +} + +func Aliases(aliases []string) string { + return AliasStyle.Render(fmt.Sprintf("(aliases: %s)", strings.Join(aliases, ", "))) +} + +func Hint(s string) string { + return HintStyle.Render(s) +} + +func Success(s string) string { + return SuccessStyle.Render(s) +} + +func Warning(s string) string { + return WarningStyle.Render(s) +} + +func Error(s string) string { + return ErrorStyle.Render(s) +} + +func Info(s string) string { + return InfoStyle.Render(s) +} + +func Muted(s string) string { + return MutedStyle.Render(s) +} + +func RenderTable(headers []string, rows [][]string) string { + t := table.New().Border(lipgloss.HiddenBorder()).Headers(headers...).Rows(rows...) + t = t.StyleFunc(func(row, col int) lipgloss.Style { + style := lipgloss.NewStyle().PaddingRight(2) + if row == table.HeaderRow { + return style.Bold(true).Faint(true) + } + return style + }) + return t.String() +} diff --git a/packages/browseros/tools/bdev/internal/workspace/config.go b/packages/browseros/tools/bdev/internal/workspace/config.go new file mode 100644 index 000000000..c3fe232ea --- /dev/null +++ b/packages/browseros/tools/bdev/internal/workspace/config.go @@ -0,0 +1,63 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Version int `yaml:"version" json:"version"` + PatchesRepo string `yaml:"patches_repo,omitempty" json:"patches_repo,omitempty"` +} + +func ConfigDir() string { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "bdev") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "bdev") +} + +func ConfigPath() string { + return filepath.Join(ConfigDir(), "config.yaml") +} + +func RegistryPath() string { + return filepath.Join(ConfigDir(), "workspaces.yaml") +} + +func LoadConfig() (*Config, error) { + data, err := os.ReadFile(ConfigPath()) + if err != nil { + if os.IsNotExist(err) { + return &Config{Version: 1}, nil + } + return nil, err + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + if cfg.Version == 0 { + cfg.Version = 1 + } + return &cfg, nil +} + +func SaveConfig(cfg *Config) error { + if cfg.Version == 0 { + cfg.Version = 1 + } + if err := os.MkdirAll(ConfigDir(), 0o755); err != nil { + return err + } + body, err := yaml.Marshal(cfg) + if err != nil { + return err + } + header := "# bdev configuration\n\n" + return os.WriteFile(ConfigPath(), append([]byte(header), body...), 0o644) +} diff --git a/packages/browseros/tools/bdev/internal/workspace/detect.go b/packages/browseros/tools/bdev/internal/workspace/detect.go new file mode 100644 index 000000000..0ae8e1976 --- /dev/null +++ b/packages/browseros/tools/bdev/internal/workspace/detect.go @@ -0,0 +1,49 @@ +package workspace + +import ( + "fmt" + "path/filepath" + "strings" +) + +func Detect(reg *Registry, cwd string) (Entry, error) { + if len(reg.Workspaces) == 0 { + return Entry{}, fmt.Errorf("no workspaces registered yet") + } + abs, err := filepath.Abs(cwd) + if err != nil { + return Entry{}, err + } + clean := filepath.Clean(abs) + var best Entry + bestLen := -1 + for _, ws := range reg.Workspaces { + base := filepath.Clean(ws.Path) + if clean == base || strings.HasPrefix(clean, base+string(filepath.Separator)) { + if len(base) > bestLen { + best = ws + bestLen = len(base) + } + } + } + if bestLen == -1 { + return Entry{}, fmt.Errorf( + `not inside a registered workspace; run "bdev list" to inspect workspaces or pass one by name`, + ) + } + return best, nil +} + +func Resolve(reg *Registry, name string, cwd string, src string) (Entry, error) { + if src != "" { + path, err := NormalizeWorkspacePath(src) + if err != nil { + return Entry{}, err + } + return Entry{Name: filepath.Base(path), Path: path}, nil + } + if name != "" { + return reg.Get(name) + } + return Detect(reg, cwd) +} diff --git a/packages/browseros/tools/bdev/internal/workspace/registry.go b/packages/browseros/tools/bdev/internal/workspace/registry.go new file mode 100644 index 000000000..5e602ef1d --- /dev/null +++ b/packages/browseros/tools/bdev/internal/workspace/registry.go @@ -0,0 +1,121 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "time" + + "gopkg.in/yaml.v3" +) + +type Entry struct { + Name string `yaml:"name" json:"name"` + Path string `yaml:"path" json:"path"` + AddedAt time.Time `yaml:"added_at,omitempty" json:"added_at,omitempty"` +} + +type Registry struct { + Version int `yaml:"version" json:"version"` + Workspaces []Entry `yaml:"workspaces" json:"workspaces"` +} + +func LoadRegistry() (*Registry, error) { + data, err := os.ReadFile(RegistryPath()) + if err != nil { + if os.IsNotExist(err) { + return &Registry{Version: 1}, nil + } + return nil, err + } + var reg Registry + if err := yaml.Unmarshal(data, ®); err != nil { + return nil, fmt.Errorf("parse registry: %w", err) + } + if reg.Version == 0 { + reg.Version = 1 + } + return ®, nil +} + +func SaveRegistry(reg *Registry) error { + if reg.Version == 0 { + reg.Version = 1 + } + if err := os.MkdirAll(ConfigDir(), 0o755); err != nil { + return err + } + body, err := yaml.Marshal(reg) + if err != nil { + return err + } + header := "# bdev workspaces\n\n" + return os.WriteFile(RegistryPath(), append([]byte(header), body...), 0o644) +} + +func NormalizeWorkspacePath(raw string) (string, error) { + abs, err := filepath.Abs(raw) + if err != nil { + return "", err + } + clean := filepath.Clean(abs) + info, err := os.Stat(clean) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("workspace path is not a directory: %s", clean) + } + if _, err := os.Stat(filepath.Join(clean, ".git")); err != nil { + return "", fmt.Errorf("workspace is not a git checkout: %s", clean) + } + return clean, nil +} + +func (r *Registry) Get(name string) (Entry, error) { + for _, ws := range r.Workspaces { + if ws.Name == name { + return ws, nil + } + } + return Entry{}, fmt.Errorf("workspace %q not found", name) +} + +func (r *Registry) Add(name string, path string) (Entry, error) { + normalized, err := NormalizeWorkspacePath(path) + if err != nil { + return Entry{}, err + } + for _, ws := range r.Workspaces { + switch { + case ws.Name == name: + return Entry{}, fmt.Errorf("workspace %q already exists", name) + case ws.Path == normalized: + return Entry{}, fmt.Errorf("workspace path already registered as %q", ws.Name) + } + } + entry := Entry{Name: name, Path: normalized, AddedAt: time.Now().UTC()} + r.Workspaces = append(r.Workspaces, entry) + slices.SortFunc(r.Workspaces, func(a, b Entry) int { + if a.Name < b.Name { + return -1 + } + if a.Name > b.Name { + return 1 + } + return 0 + }) + return entry, nil +} + +func (r *Registry) Remove(name string) (Entry, error) { + for idx, ws := range r.Workspaces { + if ws.Name != name { + continue + } + r.Workspaces = append(r.Workspaces[:idx], r.Workspaces[idx+1:]...) + return ws, nil + } + return Entry{}, fmt.Errorf("workspace %q not found", name) +} diff --git a/packages/browseros/tools/bdev/internal/workspace/state.go b/packages/browseros/tools/bdev/internal/workspace/state.go new file mode 100644 index 000000000..e66da1ccf --- /dev/null +++ b/packages/browseros/tools/bdev/internal/workspace/state.go @@ -0,0 +1,70 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +type State struct { + Version int `yaml:"version" json:"version"` + Workspace string `yaml:"workspace,omitempty" json:"workspace,omitempty"` + BaseCommit string `yaml:"base_commit,omitempty" json:"base_commit,omitempty"` + LastApplyRev string `yaml:"last_apply_rev,omitempty" json:"last_apply_rev,omitempty"` + LastSyncRev string `yaml:"last_sync_rev,omitempty" json:"last_sync_rev,omitempty"` + LastExtractRev string `yaml:"last_extract_rev,omitempty" json:"last_extract_rev,omitempty"` + PendingStash string `yaml:"pending_stash,omitempty" json:"pending_stash,omitempty"` + LastApplyAt time.Time `yaml:"last_apply_at,omitempty" json:"last_apply_at,omitempty"` + LastSyncAt time.Time `yaml:"last_sync_at,omitempty" json:"last_sync_at,omitempty"` + LastExtractAt time.Time `yaml:"last_extract_at,omitempty" json:"last_extract_at,omitempty"` +} + +func StateDir(workspacePath string) string { + return filepath.Join(workspacePath, ".bdev") +} + +func StatePath(workspacePath string) string { + return filepath.Join(StateDir(workspacePath), "state.yaml") +} + +func LoadState(workspacePath string) (*State, error) { + data, err := os.ReadFile(StatePath(workspacePath)) + if err != nil { + if os.IsNotExist(err) { + return &State{Version: 1, Workspace: workspacePath}, nil + } + return nil, err + } + var state State + if err := yaml.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("parse state: %w", err) + } + if state.Version == 0 { + state.Version = 1 + } + if state.Workspace == "" { + state.Workspace = workspacePath + } + return &state, nil +} + +func SaveState(workspacePath string, state *State) error { + if state.Version == 0 { + state.Version = 1 + } + if state.Workspace == "" { + state.Workspace = workspacePath + } + if err := os.MkdirAll(StateDir(workspacePath), 0o755); err != nil { + return err + } + body, err := yaml.Marshal(state) + if err != nil { + return err + } + header := "# bdev workspace state\n\n" + return os.WriteFile(StatePath(workspacePath), append([]byte(header), body...), 0o644) +} diff --git a/packages/browseros/tools/bdev/internal/workspace/workspace_test.go b/packages/browseros/tools/bdev/internal/workspace/workspace_test.go new file mode 100644 index 000000000..265177c1a --- /dev/null +++ b/packages/browseros/tools/bdev/internal/workspace/workspace_test.go @@ -0,0 +1,55 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConfigRoundTrip(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + + cfg := &Config{Version: 1, PatchesRepo: "/tmp/browseros"} + if err := SaveConfig(cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + loaded, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if loaded.PatchesRepo != cfg.PatchesRepo { + t.Fatalf("patches repo mismatch: got %q want %q", loaded.PatchesRepo, cfg.PatchesRepo) + } +} + +func TestRegistryDetectsLongestMatchingWorkspace(t *testing.T) { + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + + root := t.TempDir() + parent := filepath.Join(root, "chromium") + child := filepath.Join(parent, "src") + for _, dir := range []string{parent, child} { + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + } + + reg := &Registry{Version: 1} + if _, err := reg.Add("parent", parent); err != nil { + t.Fatalf("add parent: %v", err) + } + if _, err := reg.Add("child", child); err != nil { + t.Fatalf("add child: %v", err) + } + + ws, err := Detect(reg, filepath.Join(child, "chrome", "browser")) + if err != nil { + t.Fatalf("Detect: %v", err) + } + if ws.Name != "child" { + t.Fatalf("expected child workspace, got %q", ws.Name) + } +} diff --git a/packages/browseros/tools/bdev/main.go b/packages/browseros/tools/bdev/main.go index 399e8403b..d15e51ebf 100644 --- a/packages/browseros/tools/bdev/main.go +++ b/packages/browseros/tools/bdev/main.go @@ -1,18 +1,7 @@ package main -import ( - "fmt" - "os" - - "bdev/cmd" -) - -var version = "dev" +import "github.com/browseros-ai/BrowserOS/packages/browseros/tools/bdev/cmd" func main() { - cmd.SetVersion(version) - if err := cmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(2) - } + cmd.Execute() }