mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
The .repos bare-clone cache was excluded from disk-usage and never reclaimed, so it grew monotonically and was invisible while doing it. On the machine in #6265 it was 3.78 GB — 29% of the workspaces root, and exactly the difference between what disk-usage reported (9.1 GiB) and what the user's file manager showed (12.88 GiB). Accounting: .repos is now measured and reported on its own line rather than skipped. It stays out of the task totals on purpose — every task in a workspace checks out from this one shared cache, so folding it into per-task numbers would attribute it to directories that do not contain it. Other daemon-internal dot-directories (.skill-cache) are no longer counted as workspaces, which is what produced bogus rows like '.skillca'. Eviction: a bare repo is removed only when all four hold — GCRepoTTL > 0, no watched workspace still claims it, no worktrees remain, and no task has created a worktree from it within the TTL (default 30d). Two decisions worth calling out: - The workspace check is a RETAIN predicate, not a delete predicate. Sync re-clones every listed repo that is missing whenever a workspace registers, which happens on every daemon start, so evicting a repo the workspace still claims just buys a full re-clone on the next restart — that moves disk cost, it does not reclaim it. Because the set only prevents deletion, a stale or empty one cannot widen what we delete. - Idleness is an explicit stamp written by CreateWorktree, not directory mtime. Restarts re-fetch every cached repo, refreshing the mtime of repos no task has checked out in months; atime is unavailable in practice (noatime on Linux, off by default on Windows). A cache with no stamp reports unknown and gets its clock started, never treated as ancient — otherwise the first cycle after an upgrade would wipe every cache on the machine. Evicting wrongly costs a re-clone, not a failure: the next task that needs the repo takes the cache-miss path in ensureRepoReady. Verified on a live runtime: both table views now show the .repos line (242.8 MiB across 3 repos) and no longer list .skill-cache as a workspace. Co-authored-by: multica-agent <github@multica.ai>
507 lines
19 KiB
Go
507 lines
19 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/internal/daemon/execenv"
|
|
)
|
|
|
|
// TaskDiskUsage describes one task workdir's footprint on disk.
|
|
//
|
|
// ParentID is the id of the record that governs this directory's lifecycle,
|
|
// discriminated by Kind (issue id, chat session id, autopilot run id, or task
|
|
// id). ParentStatus is that record's current status; it stays empty until
|
|
// ResolveParentStatuses fills it in, because ScanDiskUsage itself is purely
|
|
// local and .gc_meta.json does not persist a status.
|
|
type TaskDiskUsage struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
WorkspaceShort string `json:"workspace_short"`
|
|
TaskShort string `json:"task_short"`
|
|
Path string `json:"path"`
|
|
Kind string `json:"kind"`
|
|
ParentID string `json:"parent_id,omitempty"`
|
|
ParentStatus string `json:"parent_status"`
|
|
AgeSeconds int64 `json:"age_seconds"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
ArtifactSizeBytes int64 `json:"artifact_size_bytes"`
|
|
}
|
|
|
|
// WorkspaceDiskUsage aggregates per-workspace footprint across all tasks.
|
|
// ArtifactRatio is the fraction (0..1) of SizeBytes that the GC artifact
|
|
// cleanup could reclaim — kept here so the JSON consumer doesn't have to
|
|
// re-derive it (and so the table view can render the column without dividing
|
|
// by zero on empty workspaces).
|
|
type WorkspaceDiskUsage struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
WorkspaceShort string `json:"workspace_short"`
|
|
TaskCount int `json:"task_count"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
ArtifactSizeBytes int64 `json:"artifact_size_bytes"`
|
|
ArtifactRatio float64 `json:"artifact_ratio"`
|
|
OldestAgeSeconds int64 `json:"oldest_age_seconds"`
|
|
}
|
|
|
|
// DiskUsageReport is the full result of a single ScanDiskUsage call. Total*
|
|
// fields always reflect the entire scan, never the post-`--top` truncated
|
|
// view — consumers that need the displayed subtotals can sum the slice.
|
|
type DiskUsageReport struct {
|
|
WorkspacesRoot string `json:"workspaces_root"`
|
|
GeneratedAt time.Time `json:"generated_at"`
|
|
ArtifactPatterns []string `json:"artifact_patterns"`
|
|
ManagedArtifactSubpaths []string `json:"managed_artifact_subpaths"`
|
|
Tasks []TaskDiskUsage `json:"tasks"`
|
|
Workspaces []WorkspaceDiskUsage `json:"workspaces"`
|
|
TotalTaskCount int `json:"total_task_count"`
|
|
TotalWorkspaceCount int `json:"total_workspace_count"`
|
|
TotalSizeBytes int64 `json:"total_size_bytes"`
|
|
TotalArtifactSizeBytes int64 `json:"total_artifact_size_bytes"`
|
|
TotalArtifactRatio float64 `json:"total_artifact_ratio"`
|
|
// RepoCacheSizeBytes is the bare-repo cache (.repos) footprint. It is a
|
|
// sibling of the task directories, not one of them, so it is reported
|
|
// separately and deliberately excluded from Total*: those totals describe
|
|
// task dirs, and folding a shared cache into them would double-count it
|
|
// against per-task numbers that do not contain it.
|
|
RepoCacheSizeBytes int64 `json:"repo_cache_size_bytes"`
|
|
RepoCacheCount int `json:"repo_cache_count"`
|
|
}
|
|
|
|
// DiskUsageRoot pairs a workspaces root with the profile it was derived from
|
|
// ("" = the default root). ScanDiskUsageRoots scans each one and labels its
|
|
// report with the profile so the cross-root view can attribute footprint back
|
|
// to a profile (e.g. the Desktop app's `desktop-<host>` root).
|
|
type DiskUsageRoot struct {
|
|
Profile string
|
|
Root string
|
|
}
|
|
|
|
// RootDiskUsage is one root's report inside an AggregateDiskUsageReport.
|
|
type RootDiskUsage struct {
|
|
Profile string `json:"profile"`
|
|
Report DiskUsageReport `json:"report"`
|
|
}
|
|
|
|
// AggregateDiskUsageReport is the result of scanning several workspace roots in
|
|
// one pass. Total* fields are the grand totals across every root's full scan
|
|
// (never the post-`--top` truncated view) so the combined footprint stays
|
|
// accurate even when each root's table is trimmed for display.
|
|
type AggregateDiskUsageReport struct {
|
|
GeneratedAt time.Time `json:"generated_at"`
|
|
ArtifactPatterns []string `json:"artifact_patterns"`
|
|
ManagedArtifactSubpaths []string `json:"managed_artifact_subpaths"`
|
|
Roots []RootDiskUsage `json:"roots"`
|
|
TotalTaskCount int `json:"total_task_count"`
|
|
TotalWorkspaceCount int `json:"total_workspace_count"`
|
|
TotalSizeBytes int64 `json:"total_size_bytes"`
|
|
TotalArtifactSizeBytes int64 `json:"total_artifact_size_bytes"`
|
|
TotalArtifactRatio float64 `json:"total_artifact_ratio"`
|
|
TotalRepoCacheSizeBytes int64 `json:"total_repo_cache_size_bytes"`
|
|
TotalRepoCacheCount int `json:"total_repo_cache_count"`
|
|
}
|
|
|
|
// ScanDiskUsageRoots scans every root in order and returns the combined report.
|
|
// It reuses ScanDiskUsage per root — a missing root yields an empty per-root
|
|
// report (not an error), matching the single-root command, so a never-used
|
|
// profile root simply contributes zero. A genuinely unreadable root (e.g. a
|
|
// permission error on the root directory itself) aborts the whole scan.
|
|
func ScanDiskUsageRoots(roots []DiskUsageRoot, artifactPatterns []string) (AggregateDiskUsageReport, error) {
|
|
agg := AggregateDiskUsageReport{GeneratedAt: time.Now().UTC()}
|
|
matcher := newArtifactMatcher(artifactPatterns, execenv.ManagedReclaimableArtifactSubpaths())
|
|
agg.ArtifactPatterns = sortedKeys(matcher.basenames)
|
|
agg.ManagedArtifactSubpaths = matcher.managedSubpaths()
|
|
|
|
for _, r := range roots {
|
|
report, err := ScanDiskUsage(r.Root, artifactPatterns)
|
|
if err != nil {
|
|
return agg, err
|
|
}
|
|
agg.Roots = append(agg.Roots, RootDiskUsage{Profile: r.Profile, Report: report})
|
|
agg.TotalTaskCount += report.TotalTaskCount
|
|
agg.TotalWorkspaceCount += report.TotalWorkspaceCount
|
|
agg.TotalSizeBytes += report.TotalSizeBytes
|
|
agg.TotalArtifactSizeBytes += report.TotalArtifactSizeBytes
|
|
agg.TotalRepoCacheSizeBytes += report.RepoCacheSizeBytes
|
|
agg.TotalRepoCacheCount += report.RepoCacheCount
|
|
}
|
|
agg.TotalArtifactRatio = ratio(agg.TotalArtifactSizeBytes, agg.TotalSizeBytes)
|
|
return agg, nil
|
|
}
|
|
|
|
// DiskUsageKindUnknown is the kind reported for task directories whose
|
|
// .gc_meta.json is missing or unreadable. Mirrors how the GC orphan path
|
|
// treats them — present on disk, but no parent record we can lock onto.
|
|
const DiskUsageKindUnknown = "unknown"
|
|
|
|
// ScanDiskUsage walks workspacesRoot and returns the disk-usage report. The
|
|
// walk is read-only, never follows symlinks, and counts only regular files.
|
|
// artifactPatterns is filtered through the basename-only check used by
|
|
// cleanTaskArtifacts, and exact daemon-managed artifact paths are included, so
|
|
// the reported "artifact" footprint matches the bytes the GC would actually
|
|
// reclaim. A .git subtree counts toward the total but never toward that
|
|
// artifact footprint — see taskSize. Missing roots return an empty report
|
|
// (not an error) — a daemon that's never run yet has no directory to walk.
|
|
//
|
|
// The scan is purely local. ParentStatus is left empty; callers that want the
|
|
// STATUS column populated run ResolveParentStatuses afterwards.
|
|
func ScanDiskUsage(workspacesRoot string, artifactPatterns []string) (DiskUsageReport, error) {
|
|
report := DiskUsageReport{
|
|
WorkspacesRoot: workspacesRoot,
|
|
GeneratedAt: time.Now().UTC(),
|
|
ArtifactPatterns: nil,
|
|
}
|
|
if workspacesRoot == "" {
|
|
return report, fmt.Errorf("disk-usage: workspaces root is required")
|
|
}
|
|
|
|
matcher := newArtifactMatcher(artifactPatterns, execenv.ManagedReclaimableArtifactSubpaths())
|
|
report.ArtifactPatterns = sortedKeys(matcher.basenames)
|
|
report.ManagedArtifactSubpaths = matcher.managedSubpaths()
|
|
|
|
wsEntries, err := os.ReadDir(workspacesRoot)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return report, nil
|
|
}
|
|
return report, fmt.Errorf("disk-usage: read workspaces root: %w", err)
|
|
}
|
|
|
|
wsAgg := map[string]*WorkspaceDiskUsage{}
|
|
|
|
for _, wsEntry := range wsEntries {
|
|
if !wsEntry.IsDir() {
|
|
continue
|
|
}
|
|
// The bare-repo cache is not a workspace. Measure it separately rather
|
|
// than skipping it outright: it is reclaimed on its own schedule
|
|
// (GCRepoTTL) and used to be invisible here, which made the reported
|
|
// total disagree with the user's file manager for no stated reason.
|
|
if wsEntry.Name() == reposDirName {
|
|
report.RepoCacheSizeBytes, report.RepoCacheCount = repoCacheSize(filepath.Join(workspacesRoot, wsEntry.Name()))
|
|
continue
|
|
}
|
|
// Other dot-directories are daemon-internal caches (skill bundles and
|
|
// friends), never workspaces. Counting them as workspaces put rows like
|
|
// ".skillca" in the per-workspace table.
|
|
if strings.HasPrefix(wsEntry.Name(), ".") {
|
|
continue
|
|
}
|
|
wsID := wsEntry.Name()
|
|
wsDir := filepath.Join(workspacesRoot, wsID)
|
|
taskEntries, err := os.ReadDir(wsDir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, t := range taskEntries {
|
|
if !t.IsDir() {
|
|
continue
|
|
}
|
|
taskDir := filepath.Join(wsDir, t.Name())
|
|
usage := buildTaskUsage(taskDir, wsID, t.Name(), matcher)
|
|
|
|
report.Tasks = append(report.Tasks, usage)
|
|
report.TotalSizeBytes += usage.SizeBytes
|
|
report.TotalArtifactSizeBytes += usage.ArtifactSizeBytes
|
|
|
|
ws, ok := wsAgg[wsID]
|
|
if !ok {
|
|
ws = &WorkspaceDiskUsage{
|
|
WorkspaceID: wsID,
|
|
WorkspaceShort: ShortID(wsID),
|
|
}
|
|
wsAgg[wsID] = ws
|
|
}
|
|
ws.TaskCount++
|
|
ws.SizeBytes += usage.SizeBytes
|
|
ws.ArtifactSizeBytes += usage.ArtifactSizeBytes
|
|
if usage.AgeSeconds > ws.OldestAgeSeconds {
|
|
ws.OldestAgeSeconds = usage.AgeSeconds
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Slice(report.Tasks, func(i, j int) bool {
|
|
return report.Tasks[i].SizeBytes > report.Tasks[j].SizeBytes
|
|
})
|
|
|
|
report.Workspaces = make([]WorkspaceDiskUsage, 0, len(wsAgg))
|
|
for _, ws := range wsAgg {
|
|
ws.ArtifactRatio = ratio(ws.ArtifactSizeBytes, ws.SizeBytes)
|
|
report.Workspaces = append(report.Workspaces, *ws)
|
|
}
|
|
sort.Slice(report.Workspaces, func(i, j int) bool {
|
|
return report.Workspaces[i].SizeBytes > report.Workspaces[j].SizeBytes
|
|
})
|
|
|
|
report.TotalTaskCount = len(report.Tasks)
|
|
report.TotalWorkspaceCount = len(report.Workspaces)
|
|
report.TotalArtifactRatio = ratio(report.TotalArtifactSizeBytes, report.TotalSizeBytes)
|
|
|
|
return report, nil
|
|
}
|
|
|
|
// repoCacheSize measures the bare-repo cache and counts the repos in it.
|
|
// Layout is .repos/<workspace-id>/<repo-dir>, so the count is the number of
|
|
// second-level directories — the unit the GC evicts.
|
|
func repoCacheSize(reposRoot string) (sizeBytes int64, repoCount int) {
|
|
wsEntries, err := os.ReadDir(reposRoot)
|
|
if err != nil {
|
|
return 0, 0
|
|
}
|
|
for _, wsEntry := range wsEntries {
|
|
if !wsEntry.IsDir() {
|
|
continue
|
|
}
|
|
wsDir := filepath.Join(reposRoot, wsEntry.Name())
|
|
repoEntries, err := os.ReadDir(wsDir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, repoEntry := range repoEntries {
|
|
if !repoEntry.IsDir() {
|
|
continue
|
|
}
|
|
repoCount++
|
|
sizeBytes += dirSize(filepath.Join(wsDir, repoEntry.Name()))
|
|
}
|
|
}
|
|
return sizeBytes, repoCount
|
|
}
|
|
|
|
// ratio returns numerator / denominator, mapping 0/0 (and any 0 denominator)
|
|
// to 0 instead of NaN. Callers render the result as a percentage so a NaN
|
|
// would surface as "NaN%" in the table — guard at the source.
|
|
func ratio(numerator, denominator int64) float64 {
|
|
if denominator <= 0 {
|
|
return 0
|
|
}
|
|
return float64(numerator) / float64(denominator)
|
|
}
|
|
|
|
func buildPatternSet(patterns []string) map[string]struct{} {
|
|
set := make(map[string]struct{}, len(patterns))
|
|
for _, p := range patterns {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" || strings.ContainsAny(p, "/\\") {
|
|
continue
|
|
}
|
|
set[p] = struct{}{}
|
|
}
|
|
return set
|
|
}
|
|
|
|
func sortedKeys(set map[string]struct{}) []string {
|
|
out := make([]string, 0, len(set))
|
|
for k := range set {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func buildTaskUsage(taskDir, wsID, taskShort string, matcher artifactMatcher) TaskDiskUsage {
|
|
usage := TaskDiskUsage{
|
|
WorkspaceID: wsID,
|
|
WorkspaceShort: ShortID(wsID),
|
|
TaskShort: taskShort,
|
|
Path: taskDir,
|
|
Kind: DiskUsageKindUnknown,
|
|
}
|
|
|
|
metaPresent := false
|
|
if meta, err := execenv.ReadGCMeta(taskDir); err == nil && meta != nil {
|
|
metaPresent = true
|
|
usage.Kind = string(meta.Kind)
|
|
usage.ParentID = parentIDForMeta(meta)
|
|
if !meta.CompletedAt.IsZero() {
|
|
usage.AgeSeconds = int64(time.Since(meta.CompletedAt).Seconds())
|
|
} else if age, ok := gcMetaFileAge(taskDir); ok {
|
|
usage.AgeSeconds = int64(age.Seconds())
|
|
}
|
|
}
|
|
// With no readable metadata, use taskDir mtime just like orphanByMTime.
|
|
// Legacy readable metadata without completed_at uses its own file mtime,
|
|
// matching gcDecisionIssueResult's managed-only fallback.
|
|
if usage.AgeSeconds <= 0 && !metaPresent {
|
|
if info, err := os.Stat(taskDir); err == nil {
|
|
usage.AgeSeconds = int64(time.Since(info.ModTime()).Seconds())
|
|
}
|
|
}
|
|
|
|
usage.SizeBytes, usage.ArtifactSizeBytes = taskSize(taskDir, matcher)
|
|
return usage
|
|
}
|
|
|
|
// parentIDForMeta returns the id of the record that governs this task dir's
|
|
// lifecycle. GCMeta is a discriminated union keyed on Kind, so only the field
|
|
// matching Kind is meaningful.
|
|
func parentIDForMeta(meta *execenv.GCMeta) string {
|
|
switch meta.Kind {
|
|
case execenv.GCKindIssue:
|
|
return strings.TrimSpace(meta.IssueID)
|
|
case execenv.GCKindChat:
|
|
return strings.TrimSpace(meta.ChatSessionID)
|
|
case execenv.GCKindAutopilotRun:
|
|
return strings.TrimSpace(meta.AutopilotRunID)
|
|
case execenv.GCKindQuickCreate:
|
|
return strings.TrimSpace(meta.TaskID)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// ParentStatusFetcher resolves a batch of issue ids in one workspace to their
|
|
// current status. Ids the server does not return (deleted, or invisible to
|
|
// this token) must be omitted from the result rather than mapped to a
|
|
// placeholder, so callers can tell "unresolved" from a real status.
|
|
type ParentStatusFetcher func(ctx context.Context, workspaceID string, issueIDs []string) (map[string]string, error)
|
|
|
|
// ResolveParentStatuses fills in ParentStatus on every issue-kind task in the
|
|
// report. ScanDiskUsage is deliberately network-free — this is the opt-in
|
|
// second pass that turns the STATUS column into real data.
|
|
//
|
|
// Only issue-kind tasks are resolved: they are the overwhelming majority of
|
|
// task dirs and the only kind with a batch reconciliation endpoint. Chat,
|
|
// autopilot-run, and quick-create dirs keep an empty ParentStatus rather than
|
|
// costing one request each.
|
|
//
|
|
// Best-effort by design: a workspace whose fetch fails leaves its tasks
|
|
// unresolved and the error is returned for the caller to surface, but every
|
|
// other workspace is still filled in. Callers must not treat a non-nil error
|
|
// as "the report is unusable".
|
|
func ResolveParentStatuses(ctx context.Context, report *DiskUsageReport, fetch ParentStatusFetcher) error {
|
|
if report == nil || fetch == nil {
|
|
return nil
|
|
}
|
|
|
|
idsByWorkspace := map[string][]string{}
|
|
seen := map[string]map[string]bool{}
|
|
for _, task := range report.Tasks {
|
|
if task.Kind != string(execenv.GCKindIssue) || task.ParentID == "" {
|
|
continue
|
|
}
|
|
if seen[task.WorkspaceID] == nil {
|
|
seen[task.WorkspaceID] = map[string]bool{}
|
|
}
|
|
// Several task dirs can share one issue (a re-dispatched task reuses
|
|
// the prior workdir), so de-duplicate before asking the server.
|
|
if seen[task.WorkspaceID][task.ParentID] {
|
|
continue
|
|
}
|
|
seen[task.WorkspaceID][task.ParentID] = true
|
|
idsByWorkspace[task.WorkspaceID] = append(idsByWorkspace[task.WorkspaceID], task.ParentID)
|
|
}
|
|
if len(idsByWorkspace) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var firstErr error
|
|
statuses := make(map[string]map[string]string, len(idsByWorkspace))
|
|
for workspaceID, ids := range idsByWorkspace {
|
|
resolved := make(map[string]string, len(ids))
|
|
// Same chunk size the GC loop uses, so one oversized root cannot trip
|
|
// the server's batch cap.
|
|
for start := 0; start < len(ids); start += issueGCBatchSize {
|
|
end := min(start+issueGCBatchSize, len(ids))
|
|
chunk, err := fetch(ctx, workspaceID, ids[start:end])
|
|
if err != nil {
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
for id, status := range chunk {
|
|
resolved[id] = status
|
|
}
|
|
}
|
|
statuses[workspaceID] = resolved
|
|
}
|
|
|
|
for i := range report.Tasks {
|
|
task := &report.Tasks[i]
|
|
if task.Kind != string(execenv.GCKindIssue) || task.ParentID == "" {
|
|
continue
|
|
}
|
|
if status, ok := statuses[task.WorkspaceID][task.ParentID]; ok {
|
|
task.ParentStatus = status
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
// taskSize walks taskDir and returns (totalBytes, artifactBytes). It never
|
|
// follows symlinks and counts only regular files. A directory matched by
|
|
// matcher is treated as an artifact subtree — its size is added to both totals
|
|
// and the walk does not descend further so the size matches what os.RemoveAll
|
|
// would reclaim if the GC ran cleanTaskArtifacts on it.
|
|
//
|
|
// A .git subtree is counted whole into totalBytes but never into artifactBytes.
|
|
// It is real footprint the user sees in their file manager, and a full
|
|
// gcActionClean removes it along with the rest of the task dir (dirSize, which
|
|
// reports bytes_reclaimed there, counts it too) — so excluding it made SizeBytes
|
|
// disagree with what the GC would actually free. The walk still refuses to
|
|
// descend into it, which keeps the artifact accounting aligned with
|
|
// cleanTaskArtifacts' refusal to reclaim anything inside .git.
|
|
func taskSize(taskDir string, matcher artifactMatcher) (totalBytes int64, artifactBytes int64) {
|
|
if taskDir == "" {
|
|
return
|
|
}
|
|
absRoot, err := filepath.Abs(taskDir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_ = filepath.WalkDir(absRoot, func(path string, entry os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if path == absRoot {
|
|
return nil
|
|
}
|
|
// Symlinks: never followed, never counted. WalkDir already refuses to
|
|
// descend through them, but a symlinked file would otherwise show up
|
|
// here as a non-dir entry — drop it explicitly so the size stays
|
|
// consistent with cleanTaskArtifacts' refusal to touch link targets.
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
if entry.IsDir() {
|
|
if entry.Name() == ".git" {
|
|
totalBytes += dirSize(path)
|
|
return filepath.SkipDir
|
|
}
|
|
if _, ok := matcher.matchDirectory(absRoot, path, entry); ok {
|
|
size := dirSize(path)
|
|
totalBytes += size
|
|
artifactBytes += size
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
info, infoErr := entry.Info()
|
|
if infoErr != nil {
|
|
return nil
|
|
}
|
|
if info.Mode().IsRegular() {
|
|
totalBytes += info.Size()
|
|
}
|
|
return nil
|
|
})
|
|
return
|
|
}
|
|
|
|
// ShortID returns the first 8 chars (dashes stripped) of a UUID, falling back
|
|
// to the raw input when shorter. Mirrors execenv.shortID, which lives in an
|
|
// internal subpackage and isn't exported.
|
|
func ShortID(id string) string {
|
|
s := strings.ReplaceAll(id, "-", "")
|
|
if len(s) > 8 {
|
|
return s[:8]
|
|
}
|
|
return s
|
|
}
|