mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
5 Commits
dev1/claud
...
feat/mar24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79484440d6 | ||
|
|
831aefac98 | ||
|
|
ea08211219 | ||
|
|
6c9c1dcb6c | ||
|
|
4c63eeb718 |
7
packages/browseros/tools/bdev/.gitignore
vendored
7
packages/browseros/tools/bdev/.gitignore
vendored
@@ -1,5 +1,2 @@
|
||||
bros
|
||||
bros-linux-amd64
|
||||
bros-linux-arm64
|
||||
bros-darwin-amd64
|
||||
bros-darwin-arm64
|
||||
bdev
|
||||
*.exe
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
BINARY := bdev
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
LDFLAGS := -ldflags "-X main.version=$(VERSION)"
|
||||
GOBIN := $(shell go env GOPATH)/bin
|
||||
|
||||
.PHONY: build install clean test
|
||||
.PHONY: build install test clean
|
||||
|
||||
build:
|
||||
go build $(LDFLAGS) -o $(BINARY) .
|
||||
go build -o $(BINARY) .
|
||||
|
||||
install:
|
||||
go install $(LDFLAGS) .
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
install: build
|
||||
cp $(BINARY) $(GOBIN)/$(BINARY)
|
||||
codesign --force --sign - $(GOBIN)/$(BINARY)
|
||||
@echo "Installed $(BINARY) to $(GOBIN)/$(BINARY)"
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
build-linux:
|
||||
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY)-linux-amd64 .
|
||||
|
||||
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)
|
||||
|
||||
45
packages/browseros/tools/bdev/README.md
Normal file
45
packages/browseros/tools/bdev/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# bdev
|
||||
|
||||
BrowserOS Chromium patch workflow CLI.
|
||||
|
||||
## Install
|
||||
|
||||
Requires Go 1.24+.
|
||||
|
||||
```sh
|
||||
cd tools/bdev
|
||||
make install
|
||||
```
|
||||
|
||||
This builds `bdev`, copies it into your Go bin directory, and codesigns it for local macOS execution.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
make build
|
||||
make test
|
||||
make clean
|
||||
```
|
||||
|
||||
Machine-local checkout/session state lives under `$XDG_CONFIG_HOME/bdev` or `~/.config/bdev`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```sh
|
||||
bdev init --patches-repo /path/to/browseros
|
||||
bdev apply --all --clean
|
||||
bdev export --path chrome/browser/foo.cc
|
||||
```
|
||||
|
||||
## Main Commands
|
||||
|
||||
- `bdev checkouts`
|
||||
- `bdev status`
|
||||
- `bdev sync`
|
||||
- `bdev rebase`
|
||||
- `bdev apply --all --clean`
|
||||
- `bdev conflicts`
|
||||
- `bdev resolve <path>`
|
||||
- `bdev continue`
|
||||
- `bdev export --path <file>`
|
||||
- `bdev reset --to base|synced`
|
||||
73
packages/browseros/tools/bdev/cmd/apply.go
Normal file
73
packages/browseros/tools/bdev/cmd/apply.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"bdev/internal/engine"
|
||||
"bdev/internal/registry"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
applyCheckout string
|
||||
applyClean bool
|
||||
applyAll bool
|
||||
applyTag string
|
||||
)
|
||||
|
||||
var applyCmd = &cobra.Command{
|
||||
Use: "apply",
|
||||
Short: "Apply BrowserOS patches into a checkout",
|
||||
GroupID: "work",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !applyAll {
|
||||
return fail("only --all is supported")
|
||||
}
|
||||
record, err := resolveCheckout(applyCheckout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patchCtx, err := loadPatchContext(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activity := ui.NewActivity(!jsonOutput)
|
||||
result, err := engine.ApplyAll(engine.NewContext(record, patchCtx), engine.ApplyAllOpts{
|
||||
Clean: applyClean,
|
||||
TagFeature: applyTag,
|
||||
}, activity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Session == nil {
|
||||
record.LastSyncedRev = result.RepoHead
|
||||
}
|
||||
record.LastOp = "apply"
|
||||
reg.Upsert(*record)
|
||||
if err := registry.Save(reg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(ui.Title("bdev apply"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %d\n", ui.Label("Applied:"), len(result.Applied))
|
||||
fmt.Printf(" %s %d\n", ui.Label("Deleted:"), len(result.Deleted))
|
||||
if result.Session != nil {
|
||||
fmt.Printf(" %s %d pending\n", ui.Label("Conflicts:"), len(result.Session.Pending))
|
||||
return fail("%d conflicts remain; run bdev conflicts", len(result.Session.Pending))
|
||||
}
|
||||
for _, warning := range result.Warnings {
|
||||
fmt.Printf(" %s %s\n", ui.Label("Warning:"), ui.Warn(warning))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
applyCmd.Flags().StringVar(&applyCheckout, "checkout", "", "checkout name or id")
|
||||
applyCmd.Flags().BoolVar(&applyAll, "all", false, "apply the full patch set")
|
||||
applyCmd.Flags().BoolVar(&applyClean, "clean", false, "reset checkout to base before apply")
|
||||
applyCmd.Flags().StringVar(&applyTag, "tag-feature", "", "optionally add touched files to a feature")
|
||||
rootCmd.AddCommand(applyCmd)
|
||||
}
|
||||
66
packages/browseros/tools/bdev/cmd/checkouts.go
Normal file
66
packages/browseros/tools/bdev/cmd/checkouts.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"bdev/internal/session"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var checkoutsCmd = &cobra.Command{
|
||||
Use: "checkouts",
|
||||
Short: "List registered Chromium checkouts",
|
||||
GroupID: "inspect",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
type row struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ChromiumRoot string `json:"chromium_root"`
|
||||
BrowserOSRepo string `json:"browseros_repo"`
|
||||
LastSyncedRev string `json:"last_synced_rev,omitempty"`
|
||||
ActiveSession bool `json:"active_session"`
|
||||
}
|
||||
rows := []row{}
|
||||
for _, checkout := range reg.Checkouts {
|
||||
_, err := session.Load(checkout.ID)
|
||||
rows = append(rows, row{
|
||||
ID: checkout.ID,
|
||||
Name: checkout.Name,
|
||||
ChromiumRoot: checkout.ChromiumRoot,
|
||||
BrowserOSRepo: checkout.BrowserOSRepo,
|
||||
LastSyncedRev: checkout.LastSyncedRev,
|
||||
ActiveSession: err == nil,
|
||||
})
|
||||
}
|
||||
if jsonOutput {
|
||||
data, err := json.MarshalIndent(rows, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
fmt.Println(ui.Title("bdev checkouts"))
|
||||
fmt.Println()
|
||||
for _, checkout := range rows {
|
||||
sessionState := ui.Muted("idle")
|
||||
if checkout.ActiveSession {
|
||||
sessionState = ui.Warn("active session")
|
||||
}
|
||||
fmt.Printf(" %s %s %s\n", ui.Value(checkout.Name), ui.Muted("("+checkout.ID+")"), sessionState)
|
||||
fmt.Printf(" %s %s\n", ui.Label("Chromium:"), checkout.ChromiumRoot)
|
||||
fmt.Printf(" %s %s\n", ui.Label("Repo:"), checkout.BrowserOSRepo)
|
||||
if checkout.LastSyncedRev != "" {
|
||||
fmt.Printf(" %s %s\n", ui.Label("Last sync:"), shortRev(checkout.LastSyncedRev))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(checkoutsCmd)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
49
packages/browseros/tools/bdev/cmd/conflicts.go
Normal file
49
packages/browseros/tools/bdev/cmd/conflicts.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"bdev/internal/session"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var conflictsCheckout string
|
||||
|
||||
var conflictsCmd = &cobra.Command{
|
||||
Use: "conflicts",
|
||||
Short: "Show active conflict session paths",
|
||||
GroupID: "repair",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
record, err := resolveCheckout(conflictsCheckout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess, err := session.Load(record.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOutput {
|
||||
data, err := json.MarshalIndent(sess, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
fmt.Println(ui.Title("bdev conflicts"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %s\n", ui.Label("Session:"), ui.Value(sess.Kind))
|
||||
for _, entry := range sess.Pending {
|
||||
fmt.Printf(" %s %s %s\n", ui.Warn("!"), entry.Path, ui.Muted("("+entry.Stage+")"))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
conflictsCmd.Flags().StringVar(&conflictsCheckout, "checkout", "", "checkout name or id")
|
||||
rootCmd.AddCommand(conflictsCmd)
|
||||
}
|
||||
39
packages/browseros/tools/bdev/cmd/context.go
Normal file
39
packages/browseros/tools/bdev/cmd/context.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patchrepo"
|
||||
"bdev/internal/registry"
|
||||
)
|
||||
|
||||
func resolveCheckout(name string) (*registry.CheckoutRecord, error) {
|
||||
if name != "" {
|
||||
return reg.ResolveByName(name)
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := git.Root(cwd)
|
||||
if err != nil {
|
||||
return nil, fail("not inside a git checkout and --checkout not provided")
|
||||
}
|
||||
return reg.ResolveByRoot(root)
|
||||
}
|
||||
|
||||
func loadPatchContext(record *registry.CheckoutRecord) (*patchrepo.Context, error) {
|
||||
return patchrepo.Load(record.BrowserOSRepo)
|
||||
}
|
||||
|
||||
func looksLikeChromium(dir string) bool {
|
||||
markers := []string{"chrome", "base", ".git"}
|
||||
for _, marker := range markers {
|
||||
if _, err := os.Stat(filepath.Join(dir, marker)); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
59
packages/browseros/tools/bdev/cmd/continue.go
Normal file
59
packages/browseros/tools/bdev/cmd/continue.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"bdev/internal/engine"
|
||||
"bdev/internal/registry"
|
||||
"bdev/internal/session"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var continueCheckout string
|
||||
|
||||
var continueCmd = &cobra.Command{
|
||||
Use: "continue",
|
||||
Short: "Retry pending conflict session patches",
|
||||
GroupID: "repair",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
record, err := resolveCheckout(continueCheckout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess, err := session.Load(record.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patchCtx, err := loadPatchContext(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activity := ui.NewActivity(!jsonOutput)
|
||||
result, err := engine.Continue(engine.NewContext(record, patchCtx), activity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Remaining == 0 {
|
||||
record.LastSyncedRev = sess.ToRepoRev
|
||||
}
|
||||
record.LastOp = "continue"
|
||||
reg.Upsert(*record)
|
||||
if err := registry.Save(reg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(ui.Title("bdev continue"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %d\n", ui.Label("Remaining conflicts:"), result.Remaining)
|
||||
if result.Remaining > 0 {
|
||||
return fail("%d conflicts remain", result.Remaining)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
continueCmd.Flags().StringVar(&continueCheckout, "checkout", "", "checkout name or id")
|
||||
rootCmd.AddCommand(continueCmd)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patch"
|
||||
"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)
|
||||
}
|
||||
|
||||
func runDiff(cmd *cobra.Command, args []string) error {
|
||||
ctx, err := config.LoadContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch diffDirection {
|
||||
case "push":
|
||||
return diffPush(ctx)
|
||||
case "pull":
|
||||
return diffPull(ctx)
|
||||
default:
|
||||
return fmt.Errorf("invalid direction %q — use \"push\" or \"pull\"", diffDirection)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
55
packages/browseros/tools/bdev/cmd/export.go
Normal file
55
packages/browseros/tools/bdev/cmd/export.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"bdev/internal/engine"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
exportCheckout string
|
||||
exportPaths []string
|
||||
exportTag string
|
||||
)
|
||||
|
||||
var exportCmd = &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "Export checkout changes back into chromium_patches",
|
||||
GroupID: "work",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
record, err := resolveCheckout(exportCheckout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patchCtx, err := loadPatchContext(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activity := ui.NewActivity(!jsonOutput)
|
||||
result, err := engine.Export(engine.NewContext(record, patchCtx), engine.ExportOpts{
|
||||
Paths: exportPaths,
|
||||
TagFeature: exportTag,
|
||||
}, activity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(ui.Title("bdev export"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %d\n", ui.Label("Updated patches:"), len(result.Updated))
|
||||
fmt.Printf(" %s %d\n", ui.Label("Removed patches:"), len(result.Removed))
|
||||
for _, warning := range result.Warnings {
|
||||
fmt.Printf(" %s %s\n", ui.Label("Warning:"), ui.Warn(warning))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
exportCmd.Flags().StringVar(&exportCheckout, "checkout", "", "checkout name or id")
|
||||
exportCmd.Flags().StringArrayVar(&exportPaths, "path", nil, "specific chromium path(s) to export")
|
||||
exportCmd.Flags().StringVar(&exportTag, "tag-feature", "", "optionally add touched files to a feature")
|
||||
rootCmd.AddCommand(exportCmd)
|
||||
}
|
||||
@@ -5,111 +5,69 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patchrepo"
|
||||
"bdev/internal/registry"
|
||||
"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
|
||||
)
|
||||
|
||||
var initCmd = &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Register the current Chromium checkout",
|
||||
GroupID: "setup",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !looksLikeChromium(cwd) {
|
||||
return fail("current directory does not look like a Chromium checkout")
|
||||
}
|
||||
patchCtx, err := patchrepo.Load(initPatchesRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := git.Root(cwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !git.CommitExists(root, patchCtx.BaseCommit) {
|
||||
return fail("BASE_COMMIT %s not found in checkout history", patchCtx.BaseCommit)
|
||||
}
|
||||
name := initName
|
||||
if name == "" {
|
||||
name = filepath.Base(root)
|
||||
}
|
||||
record := reg.Upsert(registry.CheckoutRecord{
|
||||
Name: name,
|
||||
ChromiumRoot: root,
|
||||
BrowserOSRepo: patchCtx.BrowserOSRepo,
|
||||
BaseCommit: patchCtx.BaseCommit,
|
||||
LastOp: "init",
|
||||
})
|
||||
if err := registry.Save(reg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(ui.Title("bdev init"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %s\n", ui.Label("Checkout:"), ui.Value(record.Name))
|
||||
fmt.Printf(" %s %s\n", ui.Label("ID:"), ui.Value(record.ID))
|
||||
fmt.Printf(" %s %s\n", ui.Label("Chromium:"), ui.Value(record.ChromiumRoot))
|
||||
fmt.Printf(" %s %s\n", ui.Label("BrowserOS repo:"), ui.Value(record.BrowserOSRepo))
|
||||
fmt.Printf(" %s %s\n", ui.Label("Base commit:"), ui.Value(shortRev(record.BaseCommit)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
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")
|
||||
initCmd.Flags().StringVar(&initPatchesRepo, "patches-repo", ".", "path to BrowserOS repo")
|
||||
initCmd.Flags().StringVar(&initName, "name", "", "human-friendly checkout name")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))))
|
||||
}
|
||||
}
|
||||
55
packages/browseros/tools/bdev/cmd/rebase.go
Normal file
55
packages/browseros/tools/bdev/cmd/rebase.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"bdev/internal/engine"
|
||||
"bdev/internal/registry"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rebaseCheckout string
|
||||
|
||||
var rebaseCmd = &cobra.Command{
|
||||
Use: "rebase",
|
||||
Short: "Restack local checkout changes on top of newer patch-repo changes",
|
||||
GroupID: "work",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
record, err := resolveCheckout(rebaseCheckout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patchCtx, err := loadPatchContext(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activity := ui.NewActivity(!jsonOutput)
|
||||
result, err := engine.Rebase(engine.NewContext(record, patchCtx), activity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Session == nil && result.RepoHead != "" {
|
||||
record.LastSyncedRev = result.RepoHead
|
||||
}
|
||||
record.LastOp = "rebase"
|
||||
reg.Upsert(*record)
|
||||
if err := registry.Save(reg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(ui.Title("bdev rebase"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %d\n", ui.Label("Updated paths:"), len(result.Updated))
|
||||
if result.Session != nil {
|
||||
fmt.Printf(" %s %d pending\n", ui.Label("Conflicts:"), len(result.Session.Pending))
|
||||
return fail("%d conflicts remain; run bdev conflicts", len(result.Session.Pending))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rebaseCmd.Flags().StringVar(&rebaseCheckout, "checkout", "", "checkout name or id")
|
||||
rootCmd.AddCommand(rebaseCmd)
|
||||
}
|
||||
61
packages/browseros/tools/bdev/cmd/reset.go
Normal file
61
packages/browseros/tools/bdev/cmd/reset.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"bdev/internal/engine"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/registry"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
resetCheckout string
|
||||
resetTarget string
|
||||
)
|
||||
|
||||
var resetCmd = &cobra.Command{
|
||||
Use: "reset",
|
||||
Short: "Reset a checkout to base or to the current synced patch state",
|
||||
GroupID: "repair",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
record, err := resolveCheckout(resetCheckout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patchCtx, err := loadPatchContext(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := engine.NewContext(record, patchCtx)
|
||||
switch resetTarget {
|
||||
case "base":
|
||||
err = engine.ResetToBase(ctx)
|
||||
case "synced":
|
||||
_, err = engine.ApplyAll(ctx, engine.ApplyAllOpts{Clean: true}, ui.NewActivity(!jsonOutput))
|
||||
if err == nil {
|
||||
record.LastSyncedRev, err = git.HeadRev(record.BrowserOSRepo)
|
||||
}
|
||||
default:
|
||||
return fail("reset target must be base or synced")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record.LastOp = "reset"
|
||||
reg.Upsert(*record)
|
||||
if err := registry.Save(reg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(ui.Success("reset"), resetTarget)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
resetCmd.Flags().StringVar(&resetCheckout, "checkout", "", "checkout name or id")
|
||||
resetCmd.Flags().StringVar(&resetTarget, "to", "base", "reset target: base or synced")
|
||||
rootCmd.AddCommand(resetCmd)
|
||||
}
|
||||
67
packages/browseros/tools/bdev/cmd/resolve.go
Normal file
67
packages/browseros/tools/bdev/cmd/resolve.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"bdev/internal/registry"
|
||||
"bdev/internal/session"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var resolveCheckoutName string
|
||||
|
||||
var resolveCmd = &cobra.Command{
|
||||
Use: "resolve <chromium/path>",
|
||||
Short: "Mark a conflict path as resolved after manual repair",
|
||||
GroupID: "repair",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
record, err := resolveCheckout(resolveCheckoutName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess, err := session.Load(record.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := args[0]
|
||||
next := sess.Pending[:0]
|
||||
found := false
|
||||
for _, entry := range sess.Pending {
|
||||
if entry.Path == path {
|
||||
found = true
|
||||
sess.Resolved = append(sess.Resolved, entry.Path)
|
||||
continue
|
||||
}
|
||||
next = append(next, entry)
|
||||
}
|
||||
if !found {
|
||||
return fail("path %s is not pending in the active session", path)
|
||||
}
|
||||
slices.Sort(sess.Resolved)
|
||||
sess.Pending = next
|
||||
if len(next) == 0 {
|
||||
if err := session.Delete(record.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
record.LastSyncedRev = sess.ToRepoRev
|
||||
record.LastOp = "resolve"
|
||||
reg.Upsert(*record)
|
||||
if err := registry.Save(reg); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := session.Save(sess); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(ui.Success("resolved"), path)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
resolveCmd.Flags().StringVar(&resolveCheckoutName, "checkout", "", "checkout name or id")
|
||||
rootCmd.AddCommand(resolveCmd)
|
||||
}
|
||||
@@ -1,31 +1,67 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/registry"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
verbose bool
|
||||
version string
|
||||
cfg *config.Config
|
||||
reg *registry.Registry
|
||||
jsonOutput bool
|
||||
)
|
||||
|
||||
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.",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "increase output detail")
|
||||
}
|
||||
|
||||
func SetVersion(v string) {
|
||||
version = v
|
||||
rootCmd.Version = v
|
||||
Use: "bdev",
|
||||
Short: "BrowserOS patch workflow CLI",
|
||||
Long: "bdev manages BrowserOS Chromium checkouts, patch sync, restacks, exports, and conflict sessions.",
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
cfg, err = config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reg, err = registry.Load()
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "output machine-readable JSON")
|
||||
rootCmd.AddGroup(
|
||||
&cobra.Group{ID: "setup", Title: "Setup:"},
|
||||
&cobra.Group{ID: "inspect", Title: "Inspect:"},
|
||||
&cobra.Group{ID: "work", Title: "Workflows:"},
|
||||
&cobra.Group{ID: "repair", Title: "Repair:"},
|
||||
)
|
||||
rootCmd.SetHelpTemplate(helpTemplate)
|
||||
cobra.AddTemplateFunc("helpHeader", func(value string) string { return value })
|
||||
cobra.AddTemplateFunc("trim", strings.TrimSpace)
|
||||
}
|
||||
|
||||
const helpTemplate = `{{helpHeader "Usage:"}}
|
||||
{{.UseLine}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
{{helpHeader "Commands:"}}{{range .Groups}}
|
||||
{{$gid := .ID}}{{.Title}}{{range $.Commands}}{{if and .IsAvailableCommand (eq .GroupID $gid)}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
|
||||
{{helpHeader "Flags:"}}
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
{{helpHeader "Global Flags:"}}
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}
|
||||
`
|
||||
|
||||
func fail(format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
@@ -4,93 +4,93 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/engine"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/session"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var statusCheckout string
|
||||
|
||||
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, "", " ")
|
||||
Use: "status",
|
||||
Short: "Show status for a registered checkout",
|
||||
GroupID: "inspect",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
record, err := resolveCheckout(statusCheckout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
patchCtx, err := loadPatchContext(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
localChanges, err := git.DiffNameStatus(record.ChromiumRoot, patchCtx.BaseCommit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repoHead, err := git.HeadRev(record.BrowserOSRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repoDirty, err := git.IsDirty(record.BrowserOSRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess, _ := session.Load(record.ID)
|
||||
payload := map[string]any{
|
||||
"id": record.ID,
|
||||
"name": record.Name,
|
||||
"chromium_root": record.ChromiumRoot,
|
||||
"browseros_repo": record.BrowserOSRepo,
|
||||
"base_commit": record.BaseCommit,
|
||||
"repo_head": repoHead,
|
||||
"repo_dirty": repoDirty,
|
||||
"local_changes": len(localChanges),
|
||||
"last_synced": record.LastSyncedRev,
|
||||
"active_session": sess != nil,
|
||||
}
|
||||
if jsonOutput {
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
fmt.Println(ui.Title("bdev status"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %s\n", ui.Label("Checkout:"), ui.Value(record.Name))
|
||||
fmt.Printf(" %s %s\n", ui.Label("Chromium:"), ui.Value(record.ChromiumRoot))
|
||||
fmt.Printf(" %s %s\n", ui.Label("BrowserOS repo:"), ui.Value(record.BrowserOSRepo))
|
||||
fmt.Printf(" %s %s\n", ui.Label("Base commit:"), ui.Value(shortRev(record.BaseCommit)))
|
||||
fmt.Printf(" %s %s\n", ui.Label("Repo head:"), ui.Value(shortRev(repoHead)))
|
||||
if patchCtx.ChromiumVersion != "" {
|
||||
fmt.Printf(" %s %s\n", ui.Label("Chromium version:"), ui.Value(patchCtx.ChromiumVersion))
|
||||
}
|
||||
fmt.Printf(" %s %d\n", ui.Label("Local changes:"), len(localChanges))
|
||||
if record.LastSyncedRev != "" {
|
||||
fmt.Printf(" %s %s\n", ui.Label("Last sync:"), ui.Value(shortRev(record.LastSyncedRev)))
|
||||
}
|
||||
if repoDirty {
|
||||
fmt.Printf(" %s %s\n", ui.Label("Patch repo state:"), ui.Warn("dirty"))
|
||||
}
|
||||
if sess != nil {
|
||||
fmt.Printf(" %s %s (%d pending)\n", ui.Label("Session:"), ui.Warn(sess.Kind), len(sess.Pending))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
func init() {
|
||||
statusCmd.Flags().StringVar(&statusCheckout, "checkout", "", "checkout name or id")
|
||||
rootCmd.AddCommand(statusCmd)
|
||||
}
|
||||
|
||||
func shortRev(rev string) string {
|
||||
if len(rev) <= 12 {
|
||||
return rev
|
||||
}
|
||||
return rev[:12]
|
||||
}
|
||||
|
||||
55
packages/browseros/tools/bdev/cmd/sync.go
Normal file
55
packages/browseros/tools/bdev/cmd/sync.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"bdev/internal/engine"
|
||||
"bdev/internal/registry"
|
||||
"bdev/internal/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var syncCheckout string
|
||||
|
||||
var syncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Apply upstream patch-repo changes onto a clean checkout",
|
||||
GroupID: "work",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
record, err := resolveCheckout(syncCheckout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patchCtx, err := loadPatchContext(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activity := ui.NewActivity(!jsonOutput)
|
||||
result, err := engine.Sync(engine.NewContext(record, patchCtx), activity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Session == nil && result.RepoHead != "" {
|
||||
record.LastSyncedRev = result.RepoHead
|
||||
}
|
||||
record.LastOp = "sync"
|
||||
reg.Upsert(*record)
|
||||
if err := registry.Save(reg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(ui.Title("bdev sync"))
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %d\n", ui.Label("Updated paths:"), len(result.Updated))
|
||||
if result.Session != nil {
|
||||
fmt.Printf(" %s %d pending\n", ui.Label("Conflicts:"), len(result.Session.Pending))
|
||||
return fail("%d conflicts remain; run bdev conflicts", len(result.Session.Pending))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
syncCmd.Flags().StringVar(&syncCheckout, "checkout", "", "checkout name or id")
|
||||
rootCmd.AddCommand(syncCmd)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -9,176 +8,124 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
var bdevBinary string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "bdev-bin-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer os.RemoveAll(root)
|
||||
bdevBinary = filepath.Join(root, "bdev")
|
||||
cmd := exec.Command("go", "build", "-o", bdevBinary, ".")
|
||||
cmd.Dir = ".."
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
panic(string(out))
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestApplyExportAndFeatureTag(t *testing.T) {
|
||||
env := setupScenario(t)
|
||||
runBdev(t, env, env.chromium, "init", "--patches-repo", env.repo, "--name", "ch1")
|
||||
runBdev(t, env, env.chromium, "apply", "--all", "--clean")
|
||||
assertFileContains(t, filepath.Join(env.chromium, env.path), "upstream-1")
|
||||
|
||||
writeFile(t, filepath.Join(env.chromium, env.path), "upstream-2\nline2\n")
|
||||
runBdev(t, env, env.chromium, "export", "--path", env.path, "--tag-feature", "test-feature")
|
||||
|
||||
assertFileContains(t, filepath.Join(env.repo, "chromium_patches", env.path), "upstream-2")
|
||||
assertFileContains(t, filepath.Join(env.repo, "build", "features.yaml"), "test-feature")
|
||||
assertFileContains(t, filepath.Join(env.repo, "build", "features.yaml"), env.path)
|
||||
}
|
||||
|
||||
func TestHelpListsGroupedCommands(t *testing.T) {
|
||||
env := setupScenario(t)
|
||||
out := runBdev(t, env, env.chromium, "--help")
|
||||
for _, want := range []string{"Setup:", "Inspect:", "Workflows:", "Repair:", "init", "checkouts", "apply", "reset"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("expected help output to contain %q\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebaseReplaysLocalOverlay(t *testing.T) {
|
||||
env := setupScenario(t)
|
||||
runBdev(t, env, env.chromium, "init", "--patches-repo", env.repo, "--name", "ch1")
|
||||
runBdev(t, env, env.chromium, "apply", "--all", "--clean")
|
||||
|
||||
writeFile(t, filepath.Join(env.chromium, env.path), "upstream-1\nlocal-change\n")
|
||||
|
||||
updateRepoPatch(t, env.chromium, env.repo, env.baseCommit, env.path, "upstream-2\nline2\n")
|
||||
runBdev(t, env, env.chromium, "rebase")
|
||||
assertFileContains(t, filepath.Join(env.chromium, env.path), "upstream-2\nlocal-change\n")
|
||||
}
|
||||
|
||||
type scenario struct {
|
||||
root string
|
||||
repo string
|
||||
chromium string
|
||||
path string
|
||||
baseCommit string
|
||||
|
||||
patchesRemote string
|
||||
patchesRepo string
|
||||
chromiumA string
|
||||
chromiumB string
|
||||
|
||||
trackedPath string
|
||||
newPath string
|
||||
xdg 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 {
|
||||
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"))
|
||||
repo := filepath.Join(root, "browseros")
|
||||
chromium := filepath.Join(root, "chromium")
|
||||
path := filepath.ToSlash(filepath.Join("chrome", "app", "test.txt"))
|
||||
xdg := filepath.Join(root, "xdg")
|
||||
|
||||
runGit(t, root, "init", "--bare", patchesRemote)
|
||||
setupChromiumRepo(t, chromiumA)
|
||||
mkdir(t, filepath.Join(chromium, "chrome", "app"))
|
||||
mkdir(t, filepath.Join(chromium, "base"))
|
||||
runGit(t, chromium, "init")
|
||||
configRepo(t, chromium)
|
||||
writeFile(t, filepath.Join(chromium, path), "base\nline2\n")
|
||||
writeFile(t, filepath.Join(chromium, "base", ".keep"), "marker\n")
|
||||
runGit(t, chromium, "add", "-A")
|
||||
runGit(t, chromium, "commit", "-m", "base")
|
||||
baseCommit := strings.TrimSpace(runGit(t, chromium, "rev-parse", "HEAD"))
|
||||
|
||||
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"))
|
||||
runGit(t, root, "init", repo)
|
||||
configRepo(t, repo)
|
||||
writeFile(t, filepath.Join(repo, "BASE_COMMIT"), baseCommit+"\n")
|
||||
writeFile(t, filepath.Join(repo, "CHROMIUM_VERSION"), "MAJOR=146\nMINOR=0\nBUILD=7680\nPATCH=31\n")
|
||||
updateRepoPatch(t, chromium, repo, baseCommit, path, "upstream-1\nline2\n")
|
||||
|
||||
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,
|
||||
return scenario{
|
||||
root: root,
|
||||
repo: repo,
|
||||
chromium: chromium,
|
||||
path: path,
|
||||
baseCommit: baseCommit,
|
||||
xdg: xdg,
|
||||
}
|
||||
}
|
||||
|
||||
func setupChromiumRepo(t *testing.T, dir string) {
|
||||
func updateRepoPatch(t *testing.T, chromiumRepo, repo, baseCommit, relPath, content 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)
|
||||
tmp := filepath.Join(filepath.Dir(repo), "tmp-chromium")
|
||||
runGit(t, filepath.Dir(repo), "clone", "--quiet", chromiumRepo, tmp)
|
||||
configRepo(t, tmp)
|
||||
runGit(t, tmp, "checkout", "--quiet", baseCommit)
|
||||
writeFile(t, filepath.Join(tmp, relPath), content)
|
||||
diff := runGit(t, tmp, "diff", "--full-index", baseCommit, "--", relPath)
|
||||
if strings.TrimSpace(diff) == "" {
|
||||
t.Fatalf("expected non-empty diff for %s", relPath)
|
||||
t.Fatalf("expected diff for %s", relPath)
|
||||
}
|
||||
return diff
|
||||
writeFile(t, filepath.Join(repo, "chromium_patches", relPath), diff)
|
||||
runGit(t, repo, "add", "-A")
|
||||
runGit(t, repo, "commit", "-m", "update patch")
|
||||
os.RemoveAll(tmp)
|
||||
}
|
||||
|
||||
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 {
|
||||
func runBdev(t *testing.T, env scenario, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command(bdevBinary, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+env.xdg)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("bdev %v failed: %v\n%s", args, err, string(out))
|
||||
@@ -197,48 +144,34 @@ func runGit(t *testing.T, dir string, args ...string) string {
|
||||
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")
|
||||
runGit(t, dir, "config", "user.email", "bdev@example.com")
|
||||
runGit(t, dir, "config", "user.name", "bdev")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
mkdir(t, filepath.Dir(path))
|
||||
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 {
|
||||
func mkdir(t *testing.T, dir 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)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
if !strings.Contains(string(data), want) {
|
||||
t.Fatalf("expected %s to contain %q\n%s", path, want, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
module 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
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
golang.org/x/term v0.30.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,44 +1,24 @@
|
||||
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/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=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
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/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=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,41 +1,63 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Name string `yaml:"name"`
|
||||
PatchesRepo string `yaml:"patches_repo"`
|
||||
DefaultCheckout string `yaml:"default_checkout,omitempty"`
|
||||
ColorMode string `yaml:"color_mode,omitempty"`
|
||||
ProgressMode string `yaml:"progress_mode,omitempty"`
|
||||
}
|
||||
|
||||
func ReadConfig(brosDir string) (*Config, error) {
|
||||
data, err := os.ReadFile(filepath.Join(brosDir, "config.yaml"))
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
ColorMode: "auto",
|
||||
ProgressMode: "auto",
|
||||
}
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
if err := EnsureLayout(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path, err := ConfigPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config.yaml: %w", err)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
cfg := DefaultConfig()
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.ColorMode == "" {
|
||||
cfg.ColorMode = "auto"
|
||||
}
|
||||
if cfg.ProgressMode == "" {
|
||||
cfg.ProgressMode = "auto"
|
||||
}
|
||||
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)
|
||||
func Save(cfg *Config) error {
|
||||
if err := EnsureLayout(); err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := ConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling config: %w", err)
|
||||
return 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
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
69
packages/browseros/tools/bdev/internal/config/paths.go
Normal file
69
packages/browseros/tools/bdev/internal/config/paths.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func RootDir() (string, error) {
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "bdev"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".config", "bdev"), nil
|
||||
}
|
||||
|
||||
func ConfigPath() (string, error) {
|
||||
root, err := RootDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(root, "config.yaml"), nil
|
||||
}
|
||||
|
||||
func RegistryPath() (string, error) {
|
||||
root, err := RootDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(root, "checkouts.yaml"), nil
|
||||
}
|
||||
|
||||
func SessionsDir() (string, error) {
|
||||
root, err := RootDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(root, "sessions"), nil
|
||||
}
|
||||
|
||||
func LogsDir() (string, error) {
|
||||
root, err := RootDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(root, "logs"), nil
|
||||
}
|
||||
|
||||
func EnsureLayout() error {
|
||||
root, err := RootDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dirs := []string{root}
|
||||
if sessions, err := SessionsDir(); err == nil {
|
||||
dirs = append(dirs, sessions)
|
||||
}
|
||||
if logs, err := LogsDir(); err == nil {
|
||||
dirs = append(dirs, logs)
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
105
packages/browseros/tools/bdev/internal/engine/apply.go
Normal file
105
packages/browseros/tools/bdev/internal/engine/apply.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"bdev/internal/feature"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patch"
|
||||
"bdev/internal/session"
|
||||
"bdev/internal/ui"
|
||||
)
|
||||
|
||||
type ApplyAllOpts struct {
|
||||
Clean bool
|
||||
TagFeature string
|
||||
}
|
||||
|
||||
type ApplyResult struct {
|
||||
Applied []string
|
||||
Deleted []string
|
||||
Session *session.Session
|
||||
RepoHead string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
func ApplyAll(ctx *Context, opts ApplyAllOpts, activity *ui.Activity) (*ApplyResult, error) {
|
||||
if err := requireCleanPatchRepo(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
done := activity.Start("load patch set")
|
||||
patchSet, err := patch.ReadCurrentPatchSet(ctx.PatchRepo.BrowserOSRepo)
|
||||
done(err == nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opts.Clean {
|
||||
done = activity.Start("reset checkout to base")
|
||||
err = resetAllToBase(ctx)
|
||||
done(err == nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
paths := make([]string, 0, len(patchSet))
|
||||
for path := range patchSet {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
slices.Sort(paths)
|
||||
result := &ApplyResult{}
|
||||
sess := &session.Session{
|
||||
CheckoutID: ctx.Checkout.ID,
|
||||
Kind: "apply",
|
||||
}
|
||||
head, err := git.HeadRev(ctx.PatchRepo.BrowserOSRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.RepoHead = head
|
||||
sess.ToRepoRev = head
|
||||
for _, path := range paths {
|
||||
fp := patchSet[path]
|
||||
switch fp.Op {
|
||||
case patch.OpDeleted:
|
||||
if err := resetPathToBase(ctx, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Deleted = append(result.Deleted, path)
|
||||
default:
|
||||
if err := resetPathToBase(ctx, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail, err := git.Apply(ctx.Checkout.ChromiumRoot, fp.Content)
|
||||
if err != nil || detail != "" {
|
||||
sess.Pending = append(sess.Pending, session.ConflictEntry{
|
||||
Path: path,
|
||||
Stage: "apply",
|
||||
PatchContent: string(fp.Content),
|
||||
Error: detail,
|
||||
})
|
||||
continue
|
||||
}
|
||||
result.Applied = append(result.Applied, path)
|
||||
}
|
||||
}
|
||||
if len(sess.Pending) > 0 {
|
||||
if err := session.Save(sess); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Session = sess
|
||||
return result, nil
|
||||
}
|
||||
if err := session.Delete(ctx.Checkout.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opts.TagFeature != "" {
|
||||
if err := feature.TagFeature(feature.TagFeatureOpts{
|
||||
BrowserOSRepo: ctx.PatchRepo.BrowserOSRepo,
|
||||
FeatureName: opts.TagFeature,
|
||||
Paths: append(append([]string{}, result.Applied...), result.Deleted...),
|
||||
}); err != nil {
|
||||
result.Warnings = append(result.Warnings, err.Error())
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
18
packages/browseros/tools/bdev/internal/engine/context.go
Normal file
18
packages/browseros/tools/bdev/internal/engine/context.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bdev/internal/patchrepo"
|
||||
"bdev/internal/registry"
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
Checkout *registry.CheckoutRecord
|
||||
PatchRepo *patchrepo.Context
|
||||
}
|
||||
|
||||
func NewContext(record *registry.CheckoutRecord, patchCtx *patchrepo.Context) *Context {
|
||||
return &Context{
|
||||
Checkout: record,
|
||||
PatchRepo: patchCtx,
|
||||
}
|
||||
}
|
||||
50
packages/browseros/tools/bdev/internal/engine/continue.go
Normal file
50
packages/browseros/tools/bdev/internal/engine/continue.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/session"
|
||||
"bdev/internal/ui"
|
||||
)
|
||||
|
||||
type ContinueResult struct {
|
||||
Remaining int
|
||||
}
|
||||
|
||||
func Continue(ctx *Context, activity *ui.Activity) (*ContinueResult, error) {
|
||||
if err := requireCleanPatchRepo(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess, err := session.Load(ctx.Checkout.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
done := activity.Start("retry pending patches")
|
||||
next := []session.ConflictEntry{}
|
||||
for _, entry := range sess.Pending {
|
||||
detail, err := git.Apply(ctx.Checkout.ChromiumRoot, []byte(entry.PatchContent))
|
||||
if err != nil || detail != "" {
|
||||
entry.Error = detail
|
||||
next = append(next, entry)
|
||||
continue
|
||||
}
|
||||
sess.Resolved = append(sess.Resolved, entry.Path)
|
||||
}
|
||||
slices.Sort(sess.Resolved)
|
||||
sess.Pending = next
|
||||
if len(next) == 0 {
|
||||
if err := session.Delete(ctx.Checkout.ID); err != nil {
|
||||
done(false, "")
|
||||
return nil, err
|
||||
}
|
||||
done(true, "")
|
||||
return &ContinueResult{}, nil
|
||||
}
|
||||
if err := session.Save(sess); err != nil {
|
||||
done(false, "")
|
||||
return nil, err
|
||||
}
|
||||
done(true, "")
|
||||
return &ContinueResult{Remaining: len(next)}, nil
|
||||
}
|
||||
7
packages/browseros/tools/bdev/internal/engine/errors.go
Normal file
7
packages/browseros/tools/bdev/internal/engine/errors.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package engine
|
||||
|
||||
import "fmt"
|
||||
|
||||
func fail(format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
111
packages/browseros/tools/bdev/internal/engine/export.go
Normal file
111
packages/browseros/tools/bdev/internal/engine/export.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"bdev/internal/feature"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patch"
|
||||
"bdev/internal/ui"
|
||||
)
|
||||
|
||||
type ExportOpts struct {
|
||||
Paths []string
|
||||
TagFeature string
|
||||
}
|
||||
|
||||
type ExportResult struct {
|
||||
Updated []string
|
||||
Removed []string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
func Export(ctx *Context, opts ExportOpts, activity *ui.Activity) (*ExportResult, error) {
|
||||
result := &ExportResult{}
|
||||
status, err := git.DiffNameStatus(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths := opts.Paths
|
||||
if len(paths) == 0 {
|
||||
for path := range status {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
slices.Sort(paths)
|
||||
done := activity.Start("write exported patch files")
|
||||
for _, path := range paths {
|
||||
state, ok := status[path]
|
||||
if !ok {
|
||||
if err := patch.Remove(ctx.PatchRepo.BrowserOSRepo, path); err != nil {
|
||||
done(false, "")
|
||||
return nil, err
|
||||
}
|
||||
result.Removed = append(result.Removed, path)
|
||||
continue
|
||||
}
|
||||
switch state {
|
||||
case "D":
|
||||
if err := patch.Write(ctx.PatchRepo.BrowserOSRepo, &patch.FilePatch{Path: path, Op: patch.OpDeleted}); err != nil {
|
||||
done(false, "")
|
||||
return nil, err
|
||||
}
|
||||
result.Updated = append(result.Updated, path)
|
||||
default:
|
||||
diff, err := git.DiffFile(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit, path)
|
||||
if err != nil {
|
||||
done(false, "")
|
||||
return nil, err
|
||||
}
|
||||
if len(diff) == 0 {
|
||||
diff, err = git.DiffNoIndex("/dev/null", filepath.Join(ctx.Checkout.ChromiumRoot, path))
|
||||
if err != nil {
|
||||
done(false, "")
|
||||
return nil, err
|
||||
}
|
||||
diff = normalizeExportPatch(path, diff)
|
||||
}
|
||||
if err := patch.Write(ctx.PatchRepo.BrowserOSRepo, &patch.FilePatch{Path: path, Op: patch.OpPatch, Content: diff}); err != nil {
|
||||
done(false, "")
|
||||
return nil, err
|
||||
}
|
||||
result.Updated = append(result.Updated, path)
|
||||
}
|
||||
}
|
||||
if opts.TagFeature != "" {
|
||||
if err := feature.TagFeature(feature.TagFeatureOpts{
|
||||
BrowserOSRepo: ctx.PatchRepo.BrowserOSRepo,
|
||||
FeatureName: opts.TagFeature,
|
||||
Paths: append(append([]string{}, result.Updated...), result.Removed...),
|
||||
}); err != nil {
|
||||
result.Warnings = append(result.Warnings, err.Error())
|
||||
}
|
||||
}
|
||||
done(true, "")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeExportPatch(path string, raw []byte) []byte {
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return raw
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
for i, line := range lines {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "diff --git "):
|
||||
lines[i] = "diff --git a/" + path + " b/" + path
|
||||
case strings.HasPrefix(line, "--- "):
|
||||
if !strings.Contains(line, "/dev/null") {
|
||||
lines[i] = "--- a/" + path
|
||||
}
|
||||
case strings.HasPrefix(line, "+++ "):
|
||||
if !strings.Contains(line, "/dev/null") {
|
||||
lines[i] = "+++ b/" + path
|
||||
}
|
||||
}
|
||||
}
|
||||
return []byte(strings.Join(lines, "\n"))
|
||||
}
|
||||
184
packages/browseros/tools/bdev/internal/engine/helpers.go
Normal file
184
packages/browseros/tools/bdev/internal/engine/helpers.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patch"
|
||||
)
|
||||
|
||||
func requireCleanPatchRepo(ctx *Context) error {
|
||||
dirty, err := git.IsDirty(ctx.PatchRepo.BrowserOSRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dirty {
|
||||
return fail("BrowserOS patch repo is dirty; commit or stash changes first")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetPathToBase(ctx *Context, path string) error {
|
||||
full := filepath.Join(ctx.Checkout.ChromiumRoot, path)
|
||||
if git.FileExistsInCommit(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit, path) {
|
||||
return git.CheckoutFiles(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit, []string{path})
|
||||
}
|
||||
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetAllToBase(ctx *Context) error {
|
||||
status, err := git.DiffNameStatus(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := make([]string, 0, len(status))
|
||||
for path := range status {
|
||||
if git.FileExistsInCommit(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit, path) {
|
||||
paths = append(paths, path)
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(ctx.Checkout.ChromiumRoot, path)
|
||||
if err := os.RemoveAll(full); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
slices.Sort(paths)
|
||||
return git.CheckoutFiles(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit, paths)
|
||||
}
|
||||
|
||||
func intersect(a map[string]string, b []string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, value := range b {
|
||||
if _, ok := a[value]; ok {
|
||||
seen[value] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for value := range seen {
|
||||
out = append(out, value)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func materializeState(baseData []byte, baseExists bool, patchContent []byte, path string) ([]byte, bool, error) {
|
||||
dir, err := os.MkdirTemp("", "bdev-state-*")
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
if _, err := git.Run(dir, "init", "-q"); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
target := filepath.Join(dir, path)
|
||||
if baseExists {
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := os.WriteFile(target, baseData, 0o644); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
if len(patchContent) > 0 {
|
||||
if _, err := git.Apply(dir, patchContent); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
func buildOverlayPatch(path string, from []byte, fromExists bool, to []byte, toExists bool) ([]byte, error) {
|
||||
dir, err := os.MkdirTemp("", "bdev-overlay-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
oldRoot := filepath.Join(dir, "old")
|
||||
newRoot := filepath.Join(dir, "new")
|
||||
oldFile := filepath.Join(oldRoot, path)
|
||||
newFile := filepath.Join(newRoot, path)
|
||||
if fromExists {
|
||||
if err := os.MkdirAll(filepath.Dir(oldFile), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(oldFile, from, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if toExists {
|
||||
if err := os.MkdirAll(filepath.Dir(newFile), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(newFile, to, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
fromArg := oldFile
|
||||
if !fromExists {
|
||||
fromArg = "/dev/null"
|
||||
}
|
||||
toArg := newFile
|
||||
if !toExists {
|
||||
toArg = "/dev/null"
|
||||
}
|
||||
raw, err := git.DiffNoIndexUnified(fromArg, toArg, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
for i, line := range lines {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "diff --git "):
|
||||
lines[i] = "diff --git a/" + path + " b/" + path
|
||||
case strings.HasPrefix(line, "--- "):
|
||||
if !strings.Contains(line, "/dev/null") {
|
||||
lines[i] = "--- a/" + path
|
||||
}
|
||||
case strings.HasPrefix(line, "+++ "):
|
||||
if !strings.Contains(line, "/dev/null") {
|
||||
lines[i] = "+++ b/" + path
|
||||
}
|
||||
}
|
||||
}
|
||||
return []byte(strings.Join(lines, "\n")), nil
|
||||
}
|
||||
|
||||
func currentFile(ctx *Context, path string) ([]byte, bool, error) {
|
||||
full := filepath.Join(ctx.Checkout.ChromiumRoot, path)
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
func baseFile(ctx *Context, path string) ([]byte, bool, error) {
|
||||
return git.ShowFile(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit, path)
|
||||
}
|
||||
|
||||
func oldPatch(ctx *Context, path string) (*patch.FilePatch, bool, error) {
|
||||
if ctx.Checkout.LastSyncedRev == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
return patch.ReadPatchAtRevision(ctx.PatchRepo.BrowserOSRepo, ctx.Checkout.LastSyncedRev, path)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
134
packages/browseros/tools/bdev/internal/engine/rebase.go
Normal file
134
packages/browseros/tools/bdev/internal/engine/rebase.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patch"
|
||||
"bdev/internal/session"
|
||||
"bdev/internal/ui"
|
||||
)
|
||||
|
||||
type RebaseResult struct {
|
||||
Updated []string
|
||||
Session *session.Session
|
||||
RepoHead string
|
||||
}
|
||||
|
||||
func Rebase(ctx *Context, activity *ui.Activity) (*RebaseResult, error) {
|
||||
if ctx.Checkout.LastSyncedRev == "" {
|
||||
return nil, fail("checkout has no synced revision yet; run bdev apply --all --clean first")
|
||||
}
|
||||
if err := requireCleanPatchRepo(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
head, err := git.HeadRev(ctx.PatchRepo.BrowserOSRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &RebaseResult{RepoHead: head}
|
||||
if head == ctx.Checkout.LastSyncedRev {
|
||||
return result, nil
|
||||
}
|
||||
upstreamPaths, err := git.DiffChangedPathsBetween(ctx.PatchRepo.BrowserOSRepo, ctx.Checkout.LastSyncedRev, head)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localStatus, err := git.DiffNameStatus(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overlap := intersect(localStatus, upstreamPaths)
|
||||
if len(overlap) == 0 {
|
||||
syncResult, err := Sync(ctx, activity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RebaseResult{Updated: syncResult.Updated, Session: syncResult.Session, RepoHead: syncResult.RepoHead}, nil
|
||||
}
|
||||
overlayPatches := map[string][]byte{}
|
||||
for _, path := range overlap {
|
||||
baseData, baseExists, err := baseFile(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oldRepoPatch, ok, err := oldPatch(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oldData := baseData
|
||||
oldExists := baseExists
|
||||
if ok {
|
||||
switch oldRepoPatch.Op {
|
||||
case patch.OpDeleted:
|
||||
oldData = nil
|
||||
oldExists = false
|
||||
default:
|
||||
oldData, oldExists, err = materializeState(baseData, baseExists, oldRepoPatch.Content, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
currentData, currentExists, err := currentFile(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overlay, err := buildOverlayPatch(path, oldData, oldExists, currentData, currentExists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(overlay) > 0 {
|
||||
overlayPatches[path] = overlay
|
||||
}
|
||||
}
|
||||
sess := &session.Session{
|
||||
CheckoutID: ctx.Checkout.ID,
|
||||
Kind: "rebase",
|
||||
FromRepoRev: ctx.Checkout.LastSyncedRev,
|
||||
ToRepoRev: head,
|
||||
}
|
||||
upstreamFailed := map[string]bool{}
|
||||
for _, path := range upstreamPaths {
|
||||
fp, ok, err := patch.ReadCurrentPatch(ctx.PatchRepo.BrowserOSRepo, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := resetPathToBase(ctx, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok || fp.Op == patch.OpDeleted {
|
||||
result.Updated = append(result.Updated, path)
|
||||
continue
|
||||
}
|
||||
detail, err := git.Apply(ctx.Checkout.ChromiumRoot, fp.Content)
|
||||
if err != nil || detail != "" {
|
||||
upstreamFailed[path] = true
|
||||
sess.Pending = append(sess.Pending, session.ConflictEntry{
|
||||
Path: path, Stage: "sync", PatchContent: string(fp.Content), Error: detail,
|
||||
})
|
||||
continue
|
||||
}
|
||||
result.Updated = append(result.Updated, path)
|
||||
}
|
||||
for path, overlay := range overlayPatches {
|
||||
if upstreamFailed[path] {
|
||||
continue
|
||||
}
|
||||
detail, err := git.Apply(ctx.Checkout.ChromiumRoot, overlay)
|
||||
if err != nil || detail != "" {
|
||||
sess.Pending = append(sess.Pending, session.ConflictEntry{
|
||||
Path: path, Stage: "overlay", PatchContent: string(overlay), Error: detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(sess.Pending) > 0 {
|
||||
if err := session.Save(sess); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Session = sess
|
||||
return result, nil
|
||||
}
|
||||
if err := session.Delete(ctx.Checkout.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
12
packages/browseros/tools/bdev/internal/engine/reset.go
Normal file
12
packages/browseros/tools/bdev/internal/engine/reset.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bdev/internal/session"
|
||||
)
|
||||
|
||||
func ResetToBase(ctx *Context) error {
|
||||
if err := resetAllToBase(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return session.Delete(ctx.Checkout.ID)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"bdev/internal/config"
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patch"
|
||||
)
|
||||
|
||||
type StatusResult struct {
|
||||
CheckoutName string
|
||||
BaseCommit string
|
||||
ChromiumVersion string
|
||||
PatchesRepo string
|
||||
Ahead int
|
||||
Behind int
|
||||
Synced int
|
||||
AheadFiles []string
|
||||
BehindFiles []string
|
||||
SyncedFiles []string
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("status: reading repo patches: %w", err)
|
||||
}
|
||||
|
||||
// Read local state (working tree vs BASE)
|
||||
diffOutput, err := git.DiffFull(ctx.ChromiumDir, ctx.BaseCommit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("status: reading local diffs: %w", err)
|
||||
}
|
||||
|
||||
localPatchSet, err := patch.ParseUnifiedDiff(diffOutput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("status: parsing local diffs: %w", 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
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
89
packages/browseros/tools/bdev/internal/engine/sync.go
Normal file
89
packages/browseros/tools/bdev/internal/engine/sync.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bdev/internal/git"
|
||||
"bdev/internal/patch"
|
||||
"bdev/internal/session"
|
||||
"bdev/internal/ui"
|
||||
)
|
||||
|
||||
type SyncResult struct {
|
||||
Updated []string
|
||||
Session *session.Session
|
||||
RepoHead string
|
||||
}
|
||||
|
||||
func Sync(ctx *Context, activity *ui.Activity) (*SyncResult, error) {
|
||||
if ctx.Checkout.LastSyncedRev == "" {
|
||||
applyResult, err := ApplyAll(ctx, ApplyAllOpts{Clean: true}, activity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SyncResult{
|
||||
Updated: applyResult.Applied,
|
||||
Session: applyResult.Session,
|
||||
RepoHead: applyResult.RepoHead,
|
||||
}, nil
|
||||
}
|
||||
if err := requireCleanPatchRepo(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
head, err := git.HeadRev(ctx.PatchRepo.BrowserOSRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &SyncResult{RepoHead: head}
|
||||
if head == ctx.Checkout.LastSyncedRev {
|
||||
return result, nil
|
||||
}
|
||||
changedPaths, err := git.DiffChangedPathsBetween(ctx.PatchRepo.BrowserOSRepo, ctx.Checkout.LastSyncedRev, head)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localStatus, err := git.DiffNameStatus(ctx.Checkout.ChromiumRoot, ctx.PatchRepo.BaseCommit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overlap := intersect(localStatus, changedPaths)
|
||||
if len(overlap) > 0 {
|
||||
return nil, fail("local changes overlap upstream patch changes; run bdev rebase")
|
||||
}
|
||||
sess := &session.Session{
|
||||
CheckoutID: ctx.Checkout.ID,
|
||||
Kind: "sync",
|
||||
FromRepoRev: ctx.Checkout.LastSyncedRev,
|
||||
ToRepoRev: head,
|
||||
}
|
||||
for _, path := range changedPaths {
|
||||
fp, ok, err := patch.ReadCurrentPatch(ctx.PatchRepo.BrowserOSRepo, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := resetPathToBase(ctx, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok || fp.Op == patch.OpDeleted {
|
||||
result.Updated = append(result.Updated, path)
|
||||
continue
|
||||
}
|
||||
detail, err := git.Apply(ctx.Checkout.ChromiumRoot, fp.Content)
|
||||
if err != nil || detail != "" {
|
||||
sess.Pending = append(sess.Pending, session.ConflictEntry{
|
||||
Path: path, Stage: "sync", PatchContent: string(fp.Content), Error: detail,
|
||||
})
|
||||
continue
|
||||
}
|
||||
result.Updated = append(result.Updated, path)
|
||||
}
|
||||
if len(sess.Pending) > 0 {
|
||||
if err := session.Save(sess); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Session = sess
|
||||
return result, nil
|
||||
}
|
||||
if err := session.Delete(ctx.Checkout.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
74
packages/browseros/tools/bdev/internal/feature/tagger.go
Normal file
74
packages/browseros/tools/bdev/internal/feature/tagger.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package feature
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type featureDoc struct {
|
||||
Version string `yaml:"version,omitempty"`
|
||||
Features map[string]*featureItem `yaml:"features,omitempty"`
|
||||
}
|
||||
|
||||
type featureItem struct {
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Files []string `yaml:"files,omitempty"`
|
||||
}
|
||||
|
||||
type TagFeatureOpts struct {
|
||||
BrowserOSRepo string
|
||||
FeatureName string
|
||||
Paths []string
|
||||
}
|
||||
|
||||
func TagFeature(opts TagFeatureOpts) error {
|
||||
path := filepath.Join(opts.BrowserOSRepo, "build", "features.yaml")
|
||||
doc := featureDoc{
|
||||
Version: "1.0",
|
||||
Features: map[string]*featureItem{},
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if doc.Features == nil {
|
||||
doc.Features = map[string]*featureItem{}
|
||||
}
|
||||
item, ok := doc.Features[opts.FeatureName]
|
||||
if !ok {
|
||||
item = &featureItem{Description: "feat: " + opts.FeatureName}
|
||||
doc.Features[opts.FeatureName] = item
|
||||
}
|
||||
existing := map[string]bool{}
|
||||
for _, value := range item.Files {
|
||||
existing[value] = true
|
||||
}
|
||||
for _, value := range opts.Paths {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
existing[value] = true
|
||||
}
|
||||
}
|
||||
item.Files = item.Files[:0]
|
||||
for value := range existing {
|
||||
item.Files = append(item.Files, value)
|
||||
}
|
||||
slices.Sort(item.Files)
|
||||
data, err = yaml.Marshal(&doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package feature
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTagFeatureCreatesAndUpdatesFeature(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := TagFeature(TagFeatureOpts{
|
||||
BrowserOSRepo: root,
|
||||
FeatureName: "server",
|
||||
Paths: []string{"chrome/foo.cc", "chrome/bar.cc", "chrome/foo.cc"},
|
||||
}); err != nil {
|
||||
t.Fatalf("TagFeature create: %v", err)
|
||||
}
|
||||
if err := TagFeature(TagFeatureOpts{
|
||||
BrowserOSRepo: root,
|
||||
FeatureName: "server",
|
||||
Paths: []string{"chrome/baz.cc"},
|
||||
}); err != nil {
|
||||
t.Fatalf("TagFeature update: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, "build", "features.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read features.yaml: %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{"server:", "chrome/foo.cc", "chrome/bar.cc", "chrome/baz.cc"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("expected features.yaml to contain %q\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagFeatureReturnsReadErrors(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "build", "features.yaml")
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir features.yaml dir: %v", err)
|
||||
}
|
||||
err := TagFeature(TagFeatureOpts{
|
||||
BrowserOSRepo: root,
|
||||
FeatureName: "server",
|
||||
Paths: []string{"chrome/foo.cc"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected TagFeature to fail when features.yaml is unreadable")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,179 +1,97 @@
|
||||
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) {
|
||||
func DiffNameStatus(dir, base string) (map[string]string, error) {
|
||||
out, err := Run(dir, "diff", "--name-status", "-M", base)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("diff --name-status %s: %w", base, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]patch.FileOp)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
result := map[string]string{}
|
||||
parseNameStatus(result, out)
|
||||
untracked, err := Run(dir, "ls-files", "--others", "--exclude-standard")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, line := range strings.Split(untracked, "\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
|
||||
if line != "" {
|
||||
result[line] = "A"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
func DiffFile(dir, base, path string) ([]byte, error) {
|
||||
return RunBytes(dir, "diff", "--full-index", "-M", base, "--", path)
|
||||
}
|
||||
|
||||
// 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
|
||||
func DiffFiles(dir, base string, paths []string) ([]byte, error) {
|
||||
args := []string{"diff", "--full-index", "-M", base, "--"}
|
||||
args = append(args, paths...)
|
||||
return RunBytes(dir, args...)
|
||||
}
|
||||
|
||||
// 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
|
||||
func DiffNoIndex(oldPath, newPath string) ([]byte, error) {
|
||||
return RunExitCodeOneOK("", "diff", "--no-index", "--full-index", oldPath, newPath)
|
||||
}
|
||||
|
||||
// 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())
|
||||
func DiffNoIndexUnified(oldPath, newPath string, unified int) ([]byte, error) {
|
||||
return RunExitCodeOneOK("", "diff", "--no-index", "--full-index", fmt.Sprintf("--unified=%d", unified), oldPath, newPath)
|
||||
}
|
||||
|
||||
// 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")
|
||||
func DiffChangedPathsBetween(dir, fromRev, toRev string) ([]string, error) {
|
||||
out, err := Run(dir, "diff", "--name-status", "--find-renames", fmt.Sprintf("%s..%s", fromRev, toRev), "--", "chromium_patches")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ls-files --others: %w", err)
|
||||
return nil, 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)
|
||||
seen := 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
|
||||
for _, field := range fields[1:] {
|
||||
field = strings.TrimSpace(field)
|
||||
if strings.HasPrefix(field, "chromium_patches/") {
|
||||
field = strings.TrimPrefix(field, "chromium_patches/")
|
||||
}
|
||||
field = strings.TrimSuffix(field, ".deleted")
|
||||
field = strings.TrimSuffix(field, ".binary")
|
||||
field = strings.TrimSuffix(field, ".rename")
|
||||
if field != "" {
|
||||
seen[field] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths := make([]string, 0, len(seen))
|
||||
for p := range seen {
|
||||
paths = append(paths, p)
|
||||
for path := range seen {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func parseNameStatus(result map[string]string, out string) {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
status := string(fields[0][0])
|
||||
path := fields[len(fields)-1]
|
||||
result[path] = status
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,39 +3,56 @@ package git
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"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
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
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)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
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)
|
||||
if stderr.Len() > 0 {
|
||||
return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil, fmt.Errorf("git %s: %w\n%s", args[0], err, stderr.String())
|
||||
return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
func RunExitCodeOneOK(dir string, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = dir
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
46
packages/browseros/tools/bdev/internal/git/repo.go
Normal file
46
packages/browseros/tools/bdev/internal/git/repo.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Root(dir string) (string, error) {
|
||||
return Run(dir, "rev-parse", "--show-toplevel")
|
||||
}
|
||||
|
||||
func HeadRev(dir string) (string, error) {
|
||||
return Run(dir, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
func CommitExists(dir, ref string) bool {
|
||||
_, err := Run(dir, "cat-file", "-e", ref+"^{commit}")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func IsDirty(dir string) (bool, error) {
|
||||
out, err := Run(dir, "status", "--porcelain")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(out) != "", nil
|
||||
}
|
||||
|
||||
func FileExistsInCommit(dir, ref, path string) bool {
|
||||
_, err := Run(dir, "cat-file", "-e", ref+":"+path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func ShowFile(dir, ref, path string) ([]byte, bool, error) {
|
||||
out, err := RunBytes(dir, "show", fmt.Sprintf("%s:%s", ref, path))
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exists on disk, but not in") ||
|
||||
strings.Contains(err.Error(), "does not exist in") ||
|
||||
strings.Contains(err.Error(), "path '"+path+"' does not exist") ||
|
||||
strings.Contains(err.Error(), "path \""+path+"\" does not exist") {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
50
packages/browseros/tools/bdev/internal/git/repo_test.go
Normal file
50
packages/browseros/tools/bdev/internal/git/repo_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShowFileReturnsErrorForInvalidRef(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runGit(t, dir, "init")
|
||||
configRepo(t, dir)
|
||||
writeFile(t, dir, "test.txt", "base\n")
|
||||
runGit(t, dir, "add", "test.txt")
|
||||
runGit(t, dir, "commit", "-m", "base")
|
||||
|
||||
_, _, err := ShowFile(dir, "missing-ref", "test.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid ref error")
|
||||
}
|
||||
}
|
||||
|
||||
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 configRepo(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
runGit(t, dir, "config", "user.email", "bdev@example.com")
|
||||
runGit(t, dir, "config", "user.name", "bdev")
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, dir, path, content string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(dir, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir for %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
59
packages/browseros/tools/bdev/internal/git/worktree.go
Normal file
59
packages/browseros/tools/bdev/internal/git/worktree.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CheckoutFiles(dir, ref string, paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
args := []string{"checkout", ref, "--"}
|
||||
args = append(args, paths...)
|
||||
_, err := Run(dir, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func Apply(dir string, patchContent []byte) (string, error) {
|
||||
if err := runApply(dir, patchContent, "--ignore-whitespace", "--whitespace=nowarn", "-p1"); err == nil {
|
||||
return "", nil
|
||||
}
|
||||
if err := runApply(dir, patchContent, "--ignore-whitespace", "--whitespace=nowarn", "-p1", "--3way"); err == nil {
|
||||
return "", nil
|
||||
}
|
||||
detail, err := applyWithStderr(dir, patchContent, "--reject", "--ignore-whitespace", "--whitespace=nowarn", "-p1")
|
||||
if err != nil {
|
||||
return detail, err
|
||||
}
|
||||
return "patch applied with rejects", nil
|
||||
}
|
||||
|
||||
func runApply(dir string, patchContent []byte, flags ...string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
args := append([]string{"apply"}, flags...)
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Stdin = bytes.NewReader(patchContent)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func applyWithStderr(dir string, patchContent []byte, flags ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
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
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return "", nil
|
||||
}
|
||||
return stderr.String(), fmt.Errorf("git apply failed: %w", err)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
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 ") {
|
||||
continue
|
||||
}
|
||||
normalized = append(normalized, strings.TrimRight(line, " \t"))
|
||||
}
|
||||
return []byte(strings.Join(normalized, "\n"))
|
||||
}
|
||||
@@ -6,100 +6,85 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var diffHeaderPrefix = []byte("diff --git ")
|
||||
var diffPrefix = []byte("diff --git ")
|
||||
|
||||
// ParseUnifiedDiff parses a full `git diff` output into individual file patches.
|
||||
func ParseUnifiedDiff(raw []byte) (*PatchSet, error) {
|
||||
ps := NewPatchSet("")
|
||||
|
||||
chunks := splitDiffChunks(raw)
|
||||
for _, chunk := range chunks {
|
||||
fp, err := parseSingleDiff(chunk)
|
||||
func ParseUnifiedDiff(raw []byte) (map[string]*FilePatch, error) {
|
||||
parts := splitChunks(raw)
|
||||
patches := map[string]*FilePatch{}
|
||||
for _, part := range parts {
|
||||
fp, err := parseChunk(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing diff chunk: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
if fp != nil {
|
||||
ps.Patches[fp.Path] = fp
|
||||
patches[fp.Path] = fp
|
||||
}
|
||||
}
|
||||
|
||||
return ps, nil
|
||||
return patches, nil
|
||||
}
|
||||
|
||||
func splitDiffChunks(raw []byte) [][]byte {
|
||||
var chunks [][]byte
|
||||
lines := bytes.Split(raw, []byte("\n"))
|
||||
func ParsePath(raw []byte) (string, error) {
|
||||
fp, err := parseChunk(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if fp == nil {
|
||||
return "", fmt.Errorf("empty patch")
|
||||
}
|
||||
return fp.Path, nil
|
||||
}
|
||||
|
||||
var current [][]byte
|
||||
func splitChunks(raw []byte) [][]byte {
|
||||
lines := bytes.Split(raw, []byte("\n"))
|
||||
chunks := [][]byte{}
|
||||
current := [][]byte{}
|
||||
for _, line := range lines {
|
||||
if bytes.HasPrefix(line, diffHeaderPrefix) {
|
||||
if bytes.HasPrefix(line, diffPrefix) {
|
||||
if len(current) > 0 {
|
||||
chunks = append(chunks, bytes.Join(current, []byte("\n")))
|
||||
}
|
||||
current = [][]byte{line}
|
||||
} else if len(current) > 0 {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
func parseChunk(chunk []byte) (*FilePatch, error) {
|
||||
if len(bytes.TrimSpace(chunk)) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fp := &FilePatch{
|
||||
Op: OpModified,
|
||||
Content: chunk,
|
||||
}
|
||||
|
||||
// Parse the diff --git a/... b/... header
|
||||
lines := strings.Split(string(chunk), "\n")
|
||||
header := lines[0]
|
||||
if !strings.HasPrefix(header, "diff --git ") {
|
||||
return nil, fmt.Errorf("unexpected header: %s", header)
|
||||
return nil, fmt.Errorf("unexpected patch header")
|
||||
}
|
||||
|
||||
// Extract b/ path from the header
|
||||
parts := strings.SplitN(header, " b/", 2)
|
||||
if len(parts) == 2 {
|
||||
fp.Path = parts[1]
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("cannot parse patch header")
|
||||
}
|
||||
fp := &FilePatch{
|
||||
Path: parts[1],
|
||||
Op: OpPatch,
|
||||
Content: chunk,
|
||||
}
|
||||
|
||||
// Scan header lines for operation type
|
||||
for _, line := range lines[1:] {
|
||||
if strings.HasPrefix(line, "diff --git ") || strings.HasPrefix(line, "@@") {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
break
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(line, "new file mode"):
|
||||
fp.Op = OpAdded
|
||||
case strings.HasPrefix(line, "deleted file mode"):
|
||||
if 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 "):
|
||||
break
|
||||
}
|
||||
if 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
|
||||
}
|
||||
}
|
||||
|
||||
if fp.Path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
29
packages/browseros/tools/bdev/internal/patch/parser_test.go
Normal file
29
packages/browseros/tools/bdev/internal/patch/parser_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package patch
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseUnifiedDiffParsesPathAndNewFile(t *testing.T) {
|
||||
raw := []byte(`diff --git a/foo.txt b/foo.txt
|
||||
new file mode 100644
|
||||
index 0000000..1111111 100644
|
||||
--- /dev/null
|
||||
+++ b/foo.txt
|
||||
@@ -0,0 +1 @@
|
||||
+hello
|
||||
`)
|
||||
|
||||
patches, err := ParseUnifiedDiff(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseUnifiedDiff: %v", err)
|
||||
}
|
||||
fp := patches["foo.txt"]
|
||||
if fp == nil {
|
||||
t.Fatalf("expected foo.txt patch")
|
||||
}
|
||||
if fp.Path != "foo.txt" {
|
||||
t.Fatalf("unexpected path %q", fp.Path)
|
||||
}
|
||||
if fp.Op != OpPatch {
|
||||
t.Fatalf("unexpected op %q", fp.Op)
|
||||
}
|
||||
}
|
||||
@@ -1,159 +1,90 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
"bdev/internal/git"
|
||||
)
|
||||
|
||||
// ReadPatchSet reads all patches from the chromium_patches/ directory.
|
||||
func ReadPatchSet(patchesDir string) (*PatchSet, error) {
|
||||
ps := NewPatchSet("")
|
||||
|
||||
// Collect file paths
|
||||
var filePaths []string
|
||||
func ReadCurrentPatchSet(browserOSRepo string) (map[string]*FilePatch, error) {
|
||||
patchesDir := filepath.Join(browserOSRepo, "chromium_patches")
|
||||
patches := map[string]*FilePatch{}
|
||||
err := filepath.Walk(patchesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(patchesDir, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
filePaths = append(filePaths, path)
|
||||
fp, err := readFile(path, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patches[fp.Path] = fp
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return patches, nil
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
func ReadCurrentPatch(browserOSRepo, path string) (*FilePatch, bool, error) {
|
||||
patchesDir := filepath.Join(browserOSRepo, "chromium_patches")
|
||||
for _, rel := range []string{path + ".deleted", path} {
|
||||
full := filepath.Join(patchesDir, rel)
|
||||
if _, err := os.Stat(full); err == nil {
|
||||
fp, err := readFile(full, rel)
|
||||
return fp, true, err
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
func ReadPatchAtRevision(browserOSRepo, rev, path string) (*FilePatch, bool, error) {
|
||||
for _, rel := range []string{path + ".deleted", path} {
|
||||
content, ok, err := git.ShowFile(browserOSRepo, rev, filepath.ToSlash(filepath.Join("chromium_patches", rel)))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fp := &FilePatch{Path: path, Op: OpPatch, Content: content}
|
||||
if strings.HasSuffix(rel, ".deleted") {
|
||||
fp.Op = OpDeleted
|
||||
fp.Content = nil
|
||||
}
|
||||
return fp, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func ListPatchPaths(browserOSRepo string) ([]string, error) {
|
||||
patches, err := ReadCurrentPatchSet(browserOSRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ps, nil
|
||||
paths := make([]string, 0, len(patches))
|
||||
for path := range patches {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths, 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
|
||||
}
|
||||
func readFile(full, rel string) (*FilePatch, error) {
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := strings.TrimSuffix(rel, ".deleted")
|
||||
if strings.HasSuffix(rel, ".deleted") {
|
||||
return &FilePatch{Path: path, Op: OpDeleted}, nil
|
||||
}
|
||||
return &FilePatch{Path: path, Op: OpPatch, Content: data}, nil
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +1,14 @@
|
||||
package patch
|
||||
|
||||
type FileOp int
|
||||
type Op string
|
||||
|
||||
const (
|
||||
OpModified FileOp = iota
|
||||
OpAdded
|
||||
OpDeleted
|
||||
OpRenamed
|
||||
OpBinary
|
||||
OpPatch Op = "patch"
|
||||
OpDeleted Op = "deleted"
|
||||
)
|
||||
|
||||
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 FilePatch struct {
|
||||
Path string
|
||||
Op FileOp
|
||||
Content []byte
|
||||
OldPath string // for renames
|
||||
Similarity int // for renames
|
||||
IsBinary bool
|
||||
}
|
||||
|
||||
type PatchSet struct {
|
||||
Base string
|
||||
Patches map[string]*FilePatch // keyed by chromium path
|
||||
}
|
||||
|
||||
func NewPatchSet(base string) *PatchSet {
|
||||
return &PatchSet{
|
||||
Base: base,
|
||||
Patches: make(map[string]*FilePatch),
|
||||
}
|
||||
}
|
||||
|
||||
type PushResult struct {
|
||||
Modified []string
|
||||
Added []string
|
||||
Deleted []string
|
||||
Stale []string
|
||||
Unchanged []string
|
||||
}
|
||||
|
||||
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
|
||||
Path string
|
||||
Op Op
|
||||
Content []byte
|
||||
}
|
||||
|
||||
@@ -1,119 +1,40 @@
|
||||
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)
|
||||
})
|
||||
func Write(browserOSRepo string, fp *FilePatch) error {
|
||||
root := filepath.Join(browserOSRepo, "chromium_patches")
|
||||
if err := cleanup(root, fp.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
if fp.Op == OpDeleted {
|
||||
target := filepath.Join(root, fp.Path+".deleted")
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); 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)
|
||||
return os.WriteFile(target, []byte("deleted: "+fp.Path+"\n"), 0o644)
|
||||
}
|
||||
target := filepath.Join(root, fp.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, fp.Content, 0o644)
|
||||
}
|
||||
|
||||
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 Remove(browserOSRepo, path string) error {
|
||||
root := filepath.Join(browserOSRepo, "chromium_patches")
|
||||
return cleanup(root, path)
|
||||
}
|
||||
|
||||
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)
|
||||
func cleanup(root, path string) error {
|
||||
for _, suffix := range []string{"", ".deleted", ".binary", ".rename"} {
|
||||
full := filepath.Join(root, path+suffix)
|
||||
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("removing stale artifact %s: %w", rel, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
packages/browseros/tools/bdev/internal/patchrepo/context.go
Normal file
74
packages/browseros/tools/bdev/internal/patchrepo/context.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package patchrepo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
BrowserOSRepo string
|
||||
PatchesDir string
|
||||
BaseCommit string
|
||||
ChromiumVersion string
|
||||
}
|
||||
|
||||
func Load(repo string) (*Context, error) {
|
||||
repo, err := filepath.Abs(repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patchesDir := filepath.Join(repo, "chromium_patches")
|
||||
if _, err := os.Stat(patchesDir); err != nil {
|
||||
return nil, fmt.Errorf("chromium_patches/ not found in %s", repo)
|
||||
}
|
||||
baseCommit, err := readTrimmed(filepath.Join(repo, "BASE_COMMIT"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading BASE_COMMIT: %w", err)
|
||||
}
|
||||
version, err := readVersion(filepath.Join(repo, "CHROMIUM_VERSION"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Context{
|
||||
BrowserOSRepo: repo,
|
||||
PatchesDir: patchesDir,
|
||||
BaseCommit: baseCommit,
|
||||
ChromiumVersion: version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readTrimmed(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
func readVersion(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
vars := 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 {
|
||||
continue
|
||||
}
|
||||
vars[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if vars["MAJOR"] == "" {
|
||||
return "", nil
|
||||
}
|
||||
return fmt.Sprintf("%s.%s.%s.%s", vars["MAJOR"], vars["MINOR"], vars["BUILD"], vars["PATCH"]), nil
|
||||
}
|
||||
154
packages/browseros/tools/bdev/internal/registry/checkouts.go
Normal file
154
packages/browseros/tools/bdev/internal/registry/checkouts.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bdev/internal/config"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type CheckoutRecord struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
ChromiumRoot string `yaml:"chromium_root"`
|
||||
BrowserOSRepo string `yaml:"browseros_repo"`
|
||||
BaseCommit string `yaml:"base_commit"`
|
||||
LastSyncedRev string `yaml:"last_synced_rev,omitempty"`
|
||||
LastOp string `yaml:"last_op,omitempty"`
|
||||
UpdatedAt time.Time `yaml:"updated_at"`
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
Checkouts []CheckoutRecord `yaml:"checkouts"`
|
||||
}
|
||||
|
||||
func Load() (*Registry, error) {
|
||||
if err := config.EnsureLayout(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path, err := config.RegistryPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &Registry{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var reg Registry
|
||||
if err := yaml.Unmarshal(data, ®); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ®, nil
|
||||
}
|
||||
|
||||
func Save(reg *Registry) error {
|
||||
if err := config.EnsureLayout(); err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := config.RegistryPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := yaml.Marshal(reg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func (r *Registry) Upsert(record CheckoutRecord) CheckoutRecord {
|
||||
record.ChromiumRoot = filepath.Clean(record.ChromiumRoot)
|
||||
record.BrowserOSRepo = filepath.Clean(record.BrowserOSRepo)
|
||||
record.UpdatedAt = time.Now()
|
||||
if record.ID == "" {
|
||||
record.ID = newID(record.Name, record.ChromiumRoot)
|
||||
}
|
||||
for i := range r.Checkouts {
|
||||
if r.Checkouts[i].ChromiumRoot == record.ChromiumRoot || r.Checkouts[i].ID == record.ID {
|
||||
r.Checkouts[i] = record
|
||||
slices.SortFunc(r.Checkouts, compare)
|
||||
return record
|
||||
}
|
||||
}
|
||||
r.Checkouts = append(r.Checkouts, record)
|
||||
slices.SortFunc(r.Checkouts, compare)
|
||||
return record
|
||||
}
|
||||
|
||||
func (r *Registry) ResolveByName(name string) (*CheckoutRecord, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("empty checkout name")
|
||||
}
|
||||
for i := range r.Checkouts {
|
||||
if r.Checkouts[i].Name == name || r.Checkouts[i].ID == name {
|
||||
return &r.Checkouts[i], nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("checkout not found")
|
||||
}
|
||||
|
||||
func (r *Registry) ResolveByRoot(root string) (*CheckoutRecord, error) {
|
||||
root = filepath.Clean(root)
|
||||
for i := range r.Checkouts {
|
||||
if r.Checkouts[i].ChromiumRoot == root {
|
||||
return &r.Checkouts[i], nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("checkout not registered")
|
||||
}
|
||||
|
||||
func (r *Registry) RemoveByID(id string) bool {
|
||||
for i := range r.Checkouts {
|
||||
if r.Checkouts[i].ID == id {
|
||||
r.Checkouts = append(r.Checkouts[:i], r.Checkouts[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newID(name, root string) string {
|
||||
h := sha1.Sum([]byte(filepath.Clean(root)))
|
||||
suffix := hex.EncodeToString(h[:])[:8]
|
||||
base := slug(name)
|
||||
if base == "" {
|
||||
base = "checkout"
|
||||
}
|
||||
return base + "-" + suffix
|
||||
}
|
||||
|
||||
func slug(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = strings.ReplaceAll(value, " ", "-")
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range value {
|
||||
isAlphaNum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
||||
if isAlphaNum {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteRune('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
func compare(a, b CheckoutRecord) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
}
|
||||
69
packages/browseros/tools/bdev/internal/session/store.go
Normal file
69
packages/browseros/tools/bdev/internal/session/store.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"bdev/internal/config"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func Load(checkoutID string) (*Session, error) {
|
||||
path, err := sessionPath(checkoutID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, errors.New("no active session")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var s Session
|
||||
if err := yaml.Unmarshal(data, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func Save(s *Session) error {
|
||||
if err := config.EnsureLayout(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.UpdatedAt = time.Now()
|
||||
if s.CreatedAt.IsZero() {
|
||||
s.CreatedAt = s.UpdatedAt
|
||||
}
|
||||
path, err := sessionPath(s.CheckoutID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := yaml.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func Delete(checkoutID string) error {
|
||||
path, err := sessionPath(checkoutID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sessionPath(checkoutID string) (string, error) {
|
||||
dir, err := config.SessionsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, checkoutID+".yaml"), nil
|
||||
}
|
||||
22
packages/browseros/tools/bdev/internal/session/types.go
Normal file
22
packages/browseros/tools/bdev/internal/session/types.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package session
|
||||
|
||||
import "time"
|
||||
|
||||
type ConflictEntry struct {
|
||||
Path string `yaml:"path"`
|
||||
Stage string `yaml:"stage"`
|
||||
RejectPath string `yaml:"reject_path,omitempty"`
|
||||
PatchContent string `yaml:"patch_content,omitempty"`
|
||||
Error string `yaml:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
CheckoutID string `yaml:"checkout_id"`
|
||||
Kind string `yaml:"kind"`
|
||||
FromRepoRev string `yaml:"from_repo_rev,omitempty"`
|
||||
ToRepoRev string `yaml:"to_repo_rev,omitempty"`
|
||||
Pending []ConflictEntry `yaml:"pending"`
|
||||
Resolved []string `yaml:"resolved,omitempty"`
|
||||
CreatedAt time.Time `yaml:"created_at"`
|
||||
UpdatedAt time.Time `yaml:"updated_at"`
|
||||
}
|
||||
@@ -2,57 +2,95 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
var (
|
||||
StepPrefix = TitleStyle.Render(">")
|
||||
InfoPrefix = SubtitleStyle.Render("i")
|
||||
OkPrefix = SuccessStyle.Render("✓")
|
||||
WarnPrefix = WarningStyle.Render("!")
|
||||
ErrorPrefix = ErrorStyle.Render("x")
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
Verbose bool
|
||||
enabled bool
|
||||
spinner *spinner
|
||||
}
|
||||
|
||||
func NewActivity(verbose bool) *Activity {
|
||||
return &Activity{Verbose: verbose}
|
||||
func NewActivity(enabled bool) *Activity {
|
||||
return &Activity{
|
||||
enabled: enabled && term.IsTerminal(int(os.Stderr.Fd())),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Activity) Header(title string) {
|
||||
fmt.Println(TitleStyle.Render(title))
|
||||
fmt.Println()
|
||||
func (a *Activity) Start(format string, args ...any) func(ok bool, detail string) {
|
||||
label := fmt.Sprintf(format, args...)
|
||||
if a.enabled {
|
||||
a.spinner = newSpinner(label)
|
||||
a.spinner.start()
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, " • %s\n", label)
|
||||
}
|
||||
start := time.Now()
|
||||
return func(ok bool, detail string) {
|
||||
if a.spinner != nil {
|
||||
a.spinner.stop(ok, detail, time.Since(start))
|
||||
a.spinner = nil
|
||||
return
|
||||
}
|
||||
status := Success("done")
|
||||
if !ok {
|
||||
status = Warn("warn")
|
||||
}
|
||||
if detail != "" {
|
||||
fmt.Fprintf(os.Stderr, " %s %s (%s)\n", status, detail, time.Since(start).Round(time.Millisecond))
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " %s (%s)\n", status, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Activity) Step(format string, args ...any) {
|
||||
fmt.Printf(" %s %s\n", StepPrefix, fmt.Sprintf(format, args...))
|
||||
type spinner struct {
|
||||
label string
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func (a *Activity) Info(format string, args ...any) {
|
||||
fmt.Printf(" %s %s\n", InfoPrefix, fmt.Sprintf(format, args...))
|
||||
func newSpinner(label string) *spinner {
|
||||
return &spinner{
|
||||
label: label,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Activity) Detail(format string, args ...any) {
|
||||
if !a.Verbose {
|
||||
func (s *spinner) start() {
|
||||
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
i := 0
|
||||
ticker := time.NewTicker(90 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
fmt.Fprintf(os.Stderr, "\r %s %s", frames[i%len(frames)], s.label)
|
||||
i++
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *spinner) stop(ok bool, detail string, elapsed time.Duration) {
|
||||
close(s.stopCh)
|
||||
s.wg.Wait()
|
||||
symbol := Success("✓")
|
||||
if !ok {
|
||||
symbol = Warn("!")
|
||||
}
|
||||
fmt.Fprint(os.Stderr, "\r")
|
||||
if detail != "" {
|
||||
fmt.Fprintf(os.Stderr, " %s %s %s (%s)\n", symbol, s.label, Muted(detail), elapsed.Round(time.Millisecond))
|
||||
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)))
|
||||
fmt.Fprintf(os.Stderr, " %s %s (%s)\n", symbol, s.label, elapsed.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
21
packages/browseros/tools/bdev/internal/ui/colors.go
Normal file
21
packages/browseros/tools/bdev/internal/ui/colors.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package ui
|
||||
|
||||
import "github.com/fatih/color"
|
||||
|
||||
var (
|
||||
titleColor = color.New(color.Bold, color.FgCyan)
|
||||
labelColor = color.New(color.Bold)
|
||||
valueColor = color.New(color.FgHiWhite)
|
||||
successColor = color.New(color.FgGreen, color.Bold)
|
||||
warnColor = color.New(color.FgYellow, color.Bold)
|
||||
errorColor = color.New(color.FgRed, color.Bold)
|
||||
mutedColor = color.New(color.Faint)
|
||||
)
|
||||
|
||||
func Title(value string) string { return titleColor.Sprint(value) }
|
||||
func Label(value string) string { return labelColor.Sprint(value) }
|
||||
func Value(value string) string { return valueColor.Sprint(value) }
|
||||
func Success(value string) string { return successColor.Sprint(value) }
|
||||
func Warn(value string) string { return warnColor.Sprint(value) }
|
||||
func Error(value string) string { return errorColor.Sprint(value) }
|
||||
func Muted(value string) string { return mutedColor.Sprint(value) }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -7,12 +7,9 @@ import (
|
||||
"bdev/cmd"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
cmd.SetVersion(version)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user