| 1 | package sync |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "path" |
| 6 | "sort" |
| 7 | "strings" |
| 8 | |
| 9 | "go.bigb.es/auxilia/culpa" |
| 10 | "go.bigb.es/auxilia/legatus" |
| 11 | ) |
| 12 | |
| 13 | // Managed-block markers. EnsureGitignore owns only the lines between them; any |
| 14 | // content outside the block is preserved verbatim across rewrites. |
| 15 | const ( |
| 16 | beginMarker = "# >>> legatus managed >>>" |
| 17 | endMarker = "# <<< legatus managed <<<" |
| 18 | ) |
| 19 | |
| 20 | // EnsureGitignore writes (idempotently) a .gitignore at remoteRoot containing the |
| 21 | // given entries, deduped and sorted, inside the managed marker block. Manual |
| 22 | // lines outside the block survive. It performs no write when the managed block |
| 23 | // already matches, reporting changed=false. |
| 24 | func EnsureGitignore(ctx context.Context, conn legatus.Conn, remoteRoot string, entries []string) (bool, error) { |
| 25 | gitignore := path.Join(remoteRoot, ".gitignore") |
| 26 | merged, changed, err := planGitignore(ctx, conn, gitignore, entries) |
| 27 | if err != nil { |
| 28 | return false, err |
| 29 | } |
| 30 | if !changed { |
| 31 | return false, nil |
| 32 | } |
| 33 | if err := conn.Put(ctx, strings.NewReader(merged), gitignore, 0o644); err != nil { |
| 34 | return false, culpa.Wrapf(err, "sync: write %s", gitignore) |
| 35 | } |
| 36 | return true, nil |
| 37 | } |
| 38 | |
| 39 | // planGitignore reads the current .gitignore (empty when absent) and computes the |
| 40 | // merged content plus whether it differs — the shared read+merge used by both |
| 41 | // EnsureGitignore (which writes) and Gitignore.Check (which must not). |
| 42 | func planGitignore(ctx context.Context, conn legatus.Conn, gitignore string, entries []string) (string, bool, error) { |
| 43 | existing, _, err := readRemoteFile(ctx, conn, gitignore) |
| 44 | if err != nil { |
| 45 | return "", false, err |
| 46 | } |
| 47 | merged, changed := mergeGitignore(existing, entries) |
| 48 | return merged, changed, nil |
| 49 | } |
| 50 | |
| 51 | // mergeGitignore splices a deduped+sorted managed block into existing, preserving |
| 52 | // bytes before and after the markers. changed reports whether the result differs. |
| 53 | func mergeGitignore(existing string, entries []string) (string, bool) { |
| 54 | block := renderManagedBlock(entries) |
| 55 | lines := strings.Split(existing, "\n") |
| 56 | |
| 57 | begin, end := -1, -1 |
| 58 | for i, l := range lines { |
| 59 | switch strings.TrimSpace(l) { |
| 60 | case beginMarker: |
| 61 | if begin == -1 { |
| 62 | begin = i |
| 63 | } |
| 64 | case endMarker: |
| 65 | if begin != -1 && i >= begin && end == -1 { |
| 66 | end = i |
| 67 | } |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | var merged string |
| 72 | if begin != -1 && end != -1 { |
| 73 | parts := append([]string{}, lines[:begin]...) |
| 74 | parts = append(parts, strings.Split(block, "\n")...) |
| 75 | parts = append(parts, lines[end+1:]...) |
| 76 | merged = strings.Join(parts, "\n") |
| 77 | } else if existing == "" { |
| 78 | merged = block + "\n" |
| 79 | } else { |
| 80 | sep := existing |
| 81 | if !strings.HasSuffix(sep, "\n") { |
| 82 | sep += "\n" |
| 83 | } |
| 84 | merged = sep + block + "\n" |
| 85 | } |
| 86 | return merged, merged != existing |
| 87 | } |
| 88 | |
| 89 | // renderManagedBlock builds the marker-bracketed block from deduped, sorted entries. |
| 90 | func renderManagedBlock(entries []string) string { |
| 91 | seen := map[string]bool{} |
| 92 | uniq := make([]string, 0, len(entries)) |
| 93 | for _, e := range entries { |
| 94 | if e == "" || seen[e] { |
| 95 | continue |
| 96 | } |
| 97 | seen[e] = true |
| 98 | uniq = append(uniq, e) |
| 99 | } |
| 100 | sort.Strings(uniq) |
| 101 | |
| 102 | var b strings.Builder |
| 103 | b.WriteString(beginMarker + "\n") |
| 104 | for _, e := range uniq { |
| 105 | b.WriteString(e + "\n") |
| 106 | } |
| 107 | b.WriteString(endMarker) |
| 108 | return b.String() |
| 109 | } |
| 110 | |
| 111 | // readRemoteFile returns a remote file's raw content and whether it exists. An |
| 112 | // absent file is (",", false, nil), not an error; a real transport error raises. |
| 113 | // Unlike runShell it does not trim, so byte-exact idempotency holds. |
| 114 | func readRemoteFile(ctx context.Context, conn legatus.Conn, remote string) (string, bool, error) { |
| 115 | res, err := conn.Exec(ctx, legatus.Cmd{Path: "test", Args: []string{"-f", remote}}) |
| 116 | if err != nil { |
| 117 | return "", false, culpa.Wrapf(err, "sync: stat %s", remote) |
| 118 | } |
| 119 | if res.ExitCode != 0 { |
| 120 | return "", false, nil |
| 121 | } |
| 122 | data, err := downloadRemote(ctx, conn, remote) |
| 123 | if err != nil { |
| 124 | return "", false, err |
| 125 | } |
| 126 | return string(data), true, nil |
| 127 | } |
| 128 | |
| 129 | // Gitignore is the step form of EnsureGitignore, so a deploy Flow can ensure the |
| 130 | // managed .gitignore declaratively alongside GitGuard. |
| 131 | type Gitignore struct { |
| 132 | // RemoteRoot is the remote repository directory holding the .gitignore. |
| 133 | RemoteRoot string |
| 134 | // Entries are the patterns written inside the managed block. |
| 135 | Entries []string |
| 136 | } |
| 137 | |
| 138 | // Name identifies the step. |
| 139 | func (g *Gitignore) Name() string { return "sync.gitignore" } |
| 140 | |
| 141 | // Check reports whether the managed block would change. |
| 142 | func (g *Gitignore) Check(fc *legatus.FlowCtx) (bool, error) { |
| 143 | _, changed, err := planGitignore(fc.Ctx(), fc.Conn(), path.Join(g.RemoteRoot, ".gitignore"), g.Entries) |
| 144 | return changed, err |
| 145 | } |
| 146 | |
| 147 | // Apply writes the managed block when it differs, delegating to EnsureGitignore. |
| 148 | func (g *Gitignore) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) { |
| 149 | changed, err := EnsureGitignore(fc.Ctx(), fc.Conn(), g.RemoteRoot, g.Entries) |
| 150 | if err != nil { |
| 151 | return legatus.Outcome{}, err |
| 152 | } |
| 153 | msg := "gitignore up to date" |
| 154 | if changed { |
| 155 | msg = "gitignore updated" |
| 156 | } |
| 157 | return legatus.Outcome{Changed: changed, Message: msg}, nil |
| 158 | } |
| 159 | |