cron.go (7657B)
1 package jci 2 3 import ( 4 "bufio" 5 "bytes" 6 "crypto/sha256" 7 "fmt" 8 "os" 9 "os/exec" 10 "path/filepath" 11 "regexp" 12 "strings" 13 ) 14 15 const cronMarkerPrefix = "# JCI:" 16 17 // CronEntry represents a parsed crontab entry 18 // Additional metadata (line number, command, raw text) is populated when 19 // entries are loaded via the cron parser utilities. 20 type CronEntry struct { 21 Schedule string // e.g., "0 * * * *" 22 Name string // optional name/comment 23 Branch string // branch to run on (default: current) 24 Line int // source line number (optional) 25 Command string // parsed command (optional) 26 Raw string // raw line text (optional) 27 } 28 29 // Cron handles cron subcommands 30 func Cron(args []string) error { 31 if len(args) == 0 { 32 return fmt.Errorf("usage: git jci cron <ls|sync>") 33 } 34 35 switch args[0] { 36 case "ls": 37 return cronList() 38 case "sync": 39 return cronSync() 40 default: 41 return fmt.Errorf("unknown cron command: %s (use ls or sync)", args[0]) 42 } 43 } 44 45 // cronList shows current cron jobs from .jci/crontab and system cron 46 func cronList() error { 47 repoRoot, err := GetRepoRoot() 48 if err != nil { 49 return fmt.Errorf("not in a git repository: %w", err) 50 } 51 52 repoID := getRepoID(repoRoot) 53 54 // Show .jci/crontab entries 55 crontabFile := filepath.Join(repoRoot, ".jci", "crontab") 56 entries, err := parseCrontab(crontabFile) 57 if err != nil && !os.IsNotExist(err) { 58 return fmt.Errorf("failed to parse .jci/crontab: %w", err) 59 } 60 61 fmt.Printf("Repository: %s\n", repoRoot) 62 fmt.Printf("Repo ID: %s\n\n", repoID[:12]) 63 64 if len(entries) == 0 { 65 fmt.Println("No entries in .jci/crontab") 66 } else { 67 fmt.Println("Configured in .jci/crontab:") 68 for _, e := range entries { 69 branch := e.Branch 70 if branch == "" { 71 branch = "(current)" 72 } 73 name := e.Name 74 if name == "" { 75 name = "(unnamed)" 76 } 77 fmt.Printf(" %-20s %-15s %s\n", e.Schedule, branch, name) 78 } 79 } 80 81 // Show what's in system crontab 82 fmt.Println("\nInstalled in system cron:") 83 systemEntries, err := getSystemCronEntries(repoID) 84 if err != nil { 85 fmt.Printf(" (could not read system cron: %v)\n", err) 86 } else if len(systemEntries) == 0 { 87 fmt.Println(" (none)") 88 } else { 89 for _, line := range systemEntries { 90 fmt.Printf(" %s\n", line) 91 } 92 } 93 94 return nil 95 } 96 97 // cronSync synchronizes .jci/crontab with system cron 98 func cronSync() error { 99 repoRoot, err := GetRepoRoot() 100 if err != nil { 101 return fmt.Errorf("not in a git repository: %w", err) 102 } 103 104 repoID := getRepoID(repoRoot) 105 marker := cronMarkerPrefix + repoID 106 107 // Parse .jci/crontab 108 crontabFile := filepath.Join(repoRoot, ".jci", "crontab") 109 entries, err := parseCrontab(crontabFile) 110 if err != nil { 111 if os.IsNotExist(err) { 112 entries = nil // No crontab file = remove all entries 113 } else { 114 return fmt.Errorf("failed to parse .jci/crontab: %w", err) 115 } 116 } 117 118 // Get current system crontab 119 currentCron, err := getCurrentCrontab() 120 if err != nil { 121 return fmt.Errorf("failed to read current crontab: %w", err) 122 } 123 124 // Remove old JCI entries for this repo 125 var newLines []string 126 for _, line := range strings.Split(currentCron, "\n") { 127 if !strings.Contains(line, marker) { 128 newLines = append(newLines, line) 129 } 130 } 131 132 // Find git-jci binary path 133 jciBinary, err := findJCIBinary() 134 if err != nil { 135 return fmt.Errorf("could not find git-jci binary: %w", err) 136 } 137 138 // Add new entries 139 for _, e := range entries { 140 cmd := fmt.Sprintf("cd %s && git fetch --quiet 2>/dev/null; ", shellEscape(repoRoot)) 141 if e.Branch != "" { 142 cmd += fmt.Sprintf("git checkout --quiet %s 2>/dev/null && git pull --quiet 2>/dev/null; ", shellEscape(e.Branch)) 143 } 144 cmd += fmt.Sprintf("%s run", jciBinary) 145 146 comment := e.Name 147 if comment == "" { 148 comment = "jci" 149 } 150 151 line := fmt.Sprintf("%s %s %s [%s]", e.Schedule, cmd, marker, comment) 152 newLines = append(newLines, line) 153 } 154 155 // Write new crontab 156 newCron := strings.Join(newLines, "\n") 157 // Clean up multiple empty lines 158 for strings.Contains(newCron, "\n\n\n") { 159 newCron = strings.ReplaceAll(newCron, "\n\n\n", "\n\n") 160 } 161 newCron = strings.TrimSpace(newCron) + "\n" 162 163 if err := installCrontab(newCron); err != nil { 164 return fmt.Errorf("failed to install crontab: %w", err) 165 } 166 167 if len(entries) == 0 { 168 fmt.Printf("Removed all JCI cron entries for %s\n", repoRoot) 169 } else { 170 fmt.Printf("Synced %d cron entries for %s\n", len(entries), repoRoot) 171 } 172 173 return nil 174 } 175 176 // parseCrontab parses a .jci/crontab file 177 // Format: 178 // 179 // # comment 180 // SCHEDULE [branch:BRANCH] [name:NAME] 181 // 0 * * * * # every hour, current branch 182 // 0 0 * * * branch:main # daily at midnight, main branch 183 // */15 * * * * name:quick-test # every 15 min 184 func parseCrontab(path string) ([]CronEntry, error) { 185 f, err := os.Open(path) 186 if err != nil { 187 return nil, err 188 } 189 defer f.Close() 190 191 var entries []CronEntry 192 scanner := bufio.NewScanner(f) 193 scheduleRe := regexp.MustCompile(`^([*0-9,/-]+\s+[*0-9,/-]+\s+[*0-9,/-]+\s+[*0-9,/-]+\s+[*0-9,/-]+)\s*(.*)`) 194 195 for scanner.Scan() { 196 line := strings.TrimSpace(scanner.Text()) 197 198 // Skip empty lines and comments 199 if line == "" || strings.HasPrefix(line, "#") { 200 continue 201 } 202 203 matches := scheduleRe.FindStringSubmatch(line) 204 if matches == nil { 205 continue // Invalid line, skip 206 } 207 208 entry := CronEntry{ 209 Schedule: matches[1], 210 } 211 212 // Parse options 213 opts := matches[2] 214 for _, part := range strings.Fields(opts) { 215 if strings.HasPrefix(part, "branch:") { 216 entry.Branch = strings.TrimPrefix(part, "branch:") 217 } else if strings.HasPrefix(part, "name:") { 218 entry.Name = strings.TrimPrefix(part, "name:") 219 } 220 } 221 222 entries = append(entries, entry) 223 } 224 225 return entries, scanner.Err() 226 } 227 228 // getRepoID generates a unique ID for a repository based on its path 229 func getRepoID(repoRoot string) string { 230 h := sha256.Sum256([]byte(repoRoot)) 231 return fmt.Sprintf("%x", h) 232 } 233 234 // getCurrentCrontab returns the current user's crontab 235 func getCurrentCrontab() (string, error) { 236 cmd := exec.Command("crontab", "-l") 237 out, err := cmd.Output() 238 if err != nil { 239 // No crontab for user is not an error 240 if strings.Contains(err.Error(), "no crontab") { 241 return "", nil 242 } 243 // Check stderr for "no crontab" message 244 if exitErr, ok := err.(*exec.ExitError); ok { 245 if strings.Contains(string(exitErr.Stderr), "no crontab") { 246 return "", nil 247 } 248 } 249 return "", err 250 } 251 return string(out), nil 252 } 253 254 // installCrontab installs a new crontab 255 func installCrontab(content string) error { 256 cmd := exec.Command("crontab", "-") 257 cmd.Stdin = strings.NewReader(content) 258 var stderr bytes.Buffer 259 cmd.Stderr = &stderr 260 if err := cmd.Run(); err != nil { 261 return fmt.Errorf("%v: %s", err, stderr.String()) 262 } 263 return nil 264 } 265 266 // getSystemCronEntries returns JCI entries for this repo from system cron 267 func getSystemCronEntries(repoID string) ([]string, error) { 268 current, err := getCurrentCrontab() 269 if err != nil { 270 return nil, err 271 } 272 273 marker := cronMarkerPrefix + repoID 274 var entries []string 275 for _, line := range strings.Split(current, "\n") { 276 if strings.Contains(line, marker) { 277 entries = append(entries, line) 278 } 279 } 280 return entries, nil 281 } 282 283 // findJCIBinary finds the path to git-jci binary 284 func findJCIBinary() (string, error) { 285 // First try to find ourselves 286 exe, err := os.Executable() 287 if err == nil { 288 return exe, nil 289 } 290 291 // Try PATH 292 path, err := exec.LookPath("git-jci") 293 if err == nil { 294 return path, nil 295 } 296 297 return "", fmt.Errorf("git-jci not found in PATH") 298 } 299 300 // shellEscape escapes a string for safe use in shell 301 func shellEscape(s string) string { 302 // Simple escaping - wrap in single quotes and escape existing single quotes 303 return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" 304 }